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, packagebothy_noisewithout 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 servicebothy-host" (first bullets: own Go binary inservices/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" (claimssidandcekand 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 codes4101,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_noiseimports nopackage:flutterand has no dependency with crypto code exceptcryptography), 23.1 "Packages and ports" (rowbothy_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" (blockservices/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" (rowhost, 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.md2.12 "Hosts and theterminalcapability", SR10, SR11, SR12;docs/spec/08-identity.md6.3 "Fresh step-up for the terminal" and ID-T28.docs/api/openapi.yaml: schemasNoiseFrame,TerminalSessionOpen(cols,rowswith minimum 1 and maximum 65535,client_ephemeral_key_hash),TerminalSessionGrant(noise_protocol,prologue_version,noise_public_key,key_fingerprint),Host(noise_public_key,key_fingerprintexample),HostPairRequest(noise_public_key,auth_public_jwk).docs/api/asyncapi.yaml: channelsterminalStreamandhostData(x-close-codes4002,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, NoiseNK"),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.md5.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(gatesarch,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 forgo.modlines,.golangci.yml, the command dispatcher ofcmd/bothy);M1-74-codeql-go.md(the CodeQL rule for every trackedservices/*/go.mod);M2-23-generated-clients.md(precedent: the first component of a kind exercises its lines of the rootMakefileand fixes them). - Code on the base branch: root
Makefile(GO_MODS,DART_PKGS,GO_ON,DART_ON,want,skip, targetsarch,codegen-check,perf,fmt-check,lint,typecheck,test,coverage,osv,licenses,sonar);tools/ci/components.pyandtools/ci/tests/test_components.py;tools/ci/license_check.py;.github/workflows/ci.yml(jobsgatesandsonar),.github/workflows/codeql.yml;sonar-project.properties;.gitleaks.toml;.pre-commit-config.yaml;services/server/go.modandservices/server/.golangci.ymlif 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 ofgithub.com/flynn/noiseand of the Dart packagecryptography.
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):
- 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. 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 labelsbothy-terminal/1andbothy-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). - 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:
- Protocol
Noise_NK_25519_ChaChaPoly_SHA256after revision 34, patternNK(<- s,...,-> e, es,<- e, ee): in Go withgithub.com/flynn/noise(HandshakeNK,DH25519,CipherChaChaPoly,HashSHA256,Config.Prologue), in Dart as an own small handshake on X25519, ChaCha20-Poly1305, SHA-256 and HMAC fromcryptography(24 5.2, ADR 0068 point 2, R-TM-02). - 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 identifiersbothy-terminal/1andbothy-host-fp/1change only with a new protocol version (02 "Code layout", D-N10). - 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). - Frames after the handshake: one type byte and a body, the types
0x01startto0x07replay_endwith their directions from 24 5.4; an unknown type ends the session with close code4104(24 5.4, asyncapiterminalStream); the relay sees only length and time (SEC-TERM-1). - 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). - TM-T01: the handshake in Go and in Dart reproduces the entries for
Noise_NK_25519_ChaChaPoly_SHA256ofvectors/cacophony.txt(cacophony) andtests/vector/noise-c-basic.txt(noise-c) byte for byte; both files lie underservices/host/testdata/noise/with commit hash and license and serve Go and Dart (24 5.2, D-T19). - 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).
bothy_noiseimports nopackage:flutter, does no networking and no storage, and has no dependency with crypto code exceptcryptography; the responder exists only for tests (15 3.1 rule 7, 15 23.1).- The frame parser is fuzzed with Go fuzzing (PLAN 5 row "Security", PLAN row).
- The license gate is green for
github.com/flynn/noiseandcryptography, 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)"). - Component contract (23 3): Go module with
go.mod,go.sum, tests, golangci-lint configuration in the module, coverage at leastquality.coverage.go_min_percent, and a componentMakefilewithrelease-build(VERSION,OUT); Dart package withpubspec.yaml, tests undertest/, coverage at leastquality.coverage.dart_min_percent, doc comments enforced bypublic_member_api_docs;services/hosthasVERSIONandCHANGELOG.md(18 2 rule 1). - 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):
- Rights per machine: the additional right
terminalon the targethost, SR10, SR11 with I6 and SR12 (07 "Hosts and theterminalcapability"). This ticket adds no right, action or decision; M2-39 builds the targethostandhost.read(AZ-M60), M2-40 buildsterminal.*, 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. - 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 (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_noiseholds no step-up and no rights logic. - 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 no network code:internal/noise,internal/frameandinternal/fingerprintimport nonetpackage andbothy_noiseimports nodart:ioinlib/(testsTestPackagesImportNoNetworkandtest_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. - 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
TestErrorsCarryNoSecretsin Go anderrors carry no contentin Dart). - 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_timeoutplusterminal.idle_grace,terminal.max_session_duration, TM-T11). This ticket provides the input classification both build on:IsInputis true exactly fordataandresizefrom the device, and adataframe without bytes is rejected, so an empty frame can never count as input (testsTestFrameIsInput,TestDecodeMalformedand their Dart twins). - 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 contains no privileged code, stores no key and starts no process; nothing to enforce here. - 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(TestEphemeralKeyHashand its Dart twin) and the typed frames that let M2-45 start the PTY only afterstart(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 atpairM2-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/Makefilewithrelease-buildandrecordings(Design note 4).services/host/cmd/bothy-host/main.go,commands.goand 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_MODULESand theperfrecipe; the Dart branches oftestandcoverage(Design note 3). .github/workflows/ci.yml(jobsgatesandsonar): Go for every trackedservices/*/go.mod, Flutter for every tracked Dart package (Design note 3)..github/workflows/codeql.ymlonly under the condition of Design note 3.tools/ci/components.pyandtools/ci/tests/test_components.py:services/host/testdata/noise/selectsappsas 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; thenmake adr-index.docs/quality/gates.md,docs/quality/toolchain.md(English),CONTRIBUTING.md("Development setup": Go fromservices/*/go.mod, Flutter from the pinnedpubspec.yaml, the host service and the Dart packages).services/host/README.md,services/README.md,packages/README.md(English, status and layout).docs/RISKS.mdRK-28: the pinned versions and the date of the check in the mitigation column, facts only.docs/DECISIONS.mdsection 10: rowsD-M2-38-<n>(Design note 14). RootCHANGELOG.mdunder[Unreleased]: the gate and CI changes.
Out of scope¶
- Session token: claims, verification order, memory of
jti, comparison ofcek, checks ofosu,wdi,prfandown, the payload JSON of message 2 (v,host_version,profile), starting the PTY only afterstart, mapping decode and handshake errors to4101and4104and toclose_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_dirwith0600or DPAPI,install,doctor,pairand its fingerprint display, the local channel: M2-42. - Control and data connections,
helloandidentify, close codes4110to4114: 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.jsonis written so that the server implementation can be checked against the same vectors, and M2-42 (pairagainst the real server) depends on both. Moduleterminal: 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, rendererxterm, TM-T14, TM-T18: M4-35. The Dart architecture test,dart docin the documentation site, the workspace itself: M4-01 (Design note 8 for the takeover).- Signatures, SBOM, checksums, installation packages,
release.ymland tagshost/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 ofAGENTS.md. Never publishbothy_noise(publish_to: none). - 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 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(Got.TempDir(), pytesttmp_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 checkdo 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 withVerdict: BLOCKED <tool> missinginstead 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 extendquality/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 labelsbothy-terminal/1andbothy-host-fp/1, the package namebothy_noise, the release componenthost. 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¶
-
Go module.
module github.com/OWNER/bothy/services/host; thegoandtoolchainlines equal those ofservices/server/go.modwhen that file is tracked, otherwise follow the M1-05 rule (gonames the older maintained Go line,toolchainthe exact versiongo versionprints on the build host, at leastgo1.27.2, ADR 0005), andtest_go_directives_agreeholds both modules equal from then on. Ruling for docs/DECISIONS.md: the module path follows P-05 with the placeholderOWNER(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 ofgithub.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 indirectgolang.org/x/cryptoandgolang.org/x/syswithgo getto their newest release, because OSV-Scanner readsgo.modand reports every advisory of a listed version, reachable or not. No other dependency; tests use the standard library only..golangci.yml: a copy ofservices/server/.golangci.ymlwith thegoimportslocal 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":revivewithexportedandpackage-comments,gosec,errorlint, the formatters, never the exclusion presetcomments) and validate it withgolangci-lint config verify. -
Architecture rules of the host module. M1-01's
archrequires<module>/.go-arch-lint.ymlfor every tracked Go module, and 02 7.1 makes the registerservices/server/architecture/modules.yamlthe source for the server only. Ruling for docs/DECISIONS.md: the host module has no register; its.go-arch-lint.ymlis 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 withmake 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] }frameandfingerprintuse only the standard library;cmdimports no internal package yet (go-arch-lint rejects an emptymayDependOn, socmdhas nodepsentry until M2-42 adds one). Record the hand-maintained file in 02 "Code layout" and in the row "Go module" of 23 3. -
Root Makefile, CI and gate wiring for a second Go module and the first Dart package.
perf: M1-01 failsmake perffor every Go module withoutperf/, but 24 names no performance target for the host service. AddPERF_MODULES := services/serverwith a comment ("Go modules with performance targets: 02 "Performance targets", R-AR-20; the host service has none, 24") and run theperfloop over$(filter $(PERF_MODULES),$(GO_MODS)); when that list is empty printSKIP perf no Go module with performance targets selectedwith theskipmacro. A listed module withoutperf/still fails as M1-01 built it. Ruling for docs/DECISIONS.md.arch,codegen-checkandmigrations-lintwork per module unchanged (codegen-checkrunsgo generate ./..., which has nothing to do here;migrations-lintprints its per-module SKIP); run each once and fix only what fails.- Dart lines:
bothy_noiseis the first tracked Dart package, so the Dart lines offmt-check,lint,typecheck,testandcoveragerun for the first time. Run each withCOMPONENTS=appsand fix what does not work. Two defects are known and are fixed here:testrunsflutter testonlyif [ -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).coverageskips a package withoutcoverage/lcov.infowithcontinue; 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, jobsgatesandsonar: thesetup-gostep runsif: hashFiles('services/*/go.mod') != ''withgo-version-file: ${{ hashFiles('services/server/go.mod') != '' && 'services/server/go.mod' || 'services/host/go.mod' }}andcache-dependency-path: services/*/go.sum; theflutter-actionstep runsif: hashFiles('apps/*/pubspec.yaml', 'packages/*/pubspec.yaml') != ''withflutter-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 adapttest_ci_toolchains.pyto it.make runners-checkandmake lint(actionlint, zizmor) must stay green..github/workflows/codeql.yml: only iftools/ci/tests/test_codeql_workflow.py(M1-74) is on the base branch, add forservices/hostthesetup-gostep withgo-version-file: services/host/go.modand the build stepgo build ./...inservices/hostwithCGO_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 prefixservices/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.reportPathsgainsservices/host/coverage.out,sonar.dart.lcov.reportPathsgainspackages/bothy_noise/coverage/lcov.info; add**/testdata/**tosonar.exclusionsif M1-05 has not added it.
-
Command dispatcher and
release-build. 23 3 asks forrelease-buildwithVERSIONandOUT, 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 createscmd/bothy-hostas the dispatcher without commands, exactly likecmd/bothyof M1-05 (maincallsos.Exit(run(ctx, os.Args[1:], os.Stdout, os.Stderr));commands.goholds a table ofcommand{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 libraryflagonly). M2-42 registersinstall,pair,doctor,run,restart,session-userandpty-helper(02 "Code layout").services/host/Makefile(SPDX header,SHELL := bash,.SHELLFLAGS := -euo pipefail -clike the rootMakefile,##help texts):PLATFORMS := darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64with a comment naming 18 "Components" rowhost.make -C services/host release-build VERSION=<x.y.z> OUT=<dir>: withoutVERSIONorOUTprintusage: make release-build VERSION=<x.y.z> OUT=<dir>and exit 2; ifVERSIONdiffers from the content ofservices/host/VERSION, printrelease-build: VERSION file says <file>, not <given>and exit 1 (the wording of the root target;makeitself 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 platformCGO_ENABLED=0 GOTOOLCHAIN=local GOOS=<os> GOARCH=<arch> go build -trimpath -o <OUT>/bothy-host_<version>_<os>_<arch>[.exe] ./cmd/bothy-host,.exeonly 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).
-
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 wrapErrInvalidID. 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 (openapiclient_ephemeral_key_hash, claimcek, 24 5.3); another length givesErrInvalidKey.KeyPair{Private, Public [KeyLen]byte};GenerateKeyPair(random io.Reader) (KeyPair, error)(production passescrypto/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 computescekfrom 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}(nilEphemeral: generated fromRandom, defaultcrypto/rand.Reader).NewResponder(cfg) (*Responder, error);(*Responder).ReadMessage1(msg []byte) (payload []byte, initiatorEphemeral [KeyLen]byte, err error)(M2-45 compares the hash ofinitiatorEphemeralwithcek);(*Responder).WriteMessage2(payload []byte) ([]byte, *Transport, error). Both types haveHandshakeHash() []byteafter completion. An unexported constructor with a raw prologue serves the vector tests.Transport:Encrypt(plaintext []byte) ([]byte, error)andDecrypt(msg []byte) ([]byte, error)with the cipher states ofSplit(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
MaxMessageLenor a payload that would make one longer givesErrMessageTooLong;Encryptwith more thanMaxPlaintextLenbytes givesErrMessageTooLongwithout using a nonce;Decryptwith more thanMaxMessageLenor fewer thanTagLenbytes fails. - Ruling for docs/DECISIONS.md: after the first failed
Decrypt, or any failed handshake step, the object refuses every further call withErrBroken, 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 giveErrState. - 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
DH25519already returns an error for it; if not, use aDHFuncthat 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.EphemeralKeypairof the library for a fixed ephemeral key if the pinned version has it, otherwise a deterministicConfig.Randomreader;PeerEphemeral()andChannelBinding()(or their equivalents) give the initiator's ephemeral key and the handshake hash.
- Constants:
-
internal/frame(Go).type Type bytewithTypeStart 0x01,TypeData 0x02,TypeResize 0x03,TypeClose 0x04,TypeExit 0x05,TypeReplayBegin 0x06,TypeReplayEnd 0x07;type Sender uint8withFromDeviceandFromHost;type Frame struct{Type Type; Cols, Rows uint16; Data []byte; ExitCode int32}; constructorsStart,Data,Resize,Close,Exit,ReplayBegin,ReplayEnd;Encode(f Frame) ([]byte, error);Decode(b []byte, from Sender) (Frame, error)(the returnedDataaliasesb, documented);(Frame).IsInput() bool;MaxLen = 65519with 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 equalsnoise.MaxPlaintextLen. The package does not importnoise; 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;
startandresizecarry exactly 4 bytes, columns then rows, each 1 to 65535 (asTerminalSessionOpen);datacarries 1 to 65518 bytes (an emptydatawould count as input without input, 24 6.3);close,replay_beginandreplay_endcarry no body;exitcarries exactly 4 bytes, a signed 32-bit two's complement integer. - Directions:
start,resizeandcloseonly from the device;exit,replay_beginandreplay_endonly from the host;databoth ways. A type byte outside0x01to0x07givesErrUnknownType; a known type in the wrong direction givesErrWrongDirection; a wrong body givesErrMalformed; an empty inputErrEmpty; more thanMaxLenbytesErrTooLong(also inEncode). Ruling for docs/DECISIONS.md: every decode error ends the session like an unknown type (4104, 24 5.4, asyncapiterminalStream); M2-45 does the mapping. IsInputis true exactly fordataandresize(24 6.3 "Idle").
- 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;
-
internal/fingerprint(Go).Label = "bothy-host-fp/1";Encode(b []byte) string: 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;Compute(noisePublicKey, authPublicKey []byte) (string, error): both exactly 32 bytes (the raw X25519 key and the raw Ed25519 key, the decodedxof the JWK), result the 52 characters in 13 groups of four joined by-(the form ofkey_fingerprintin openapi.yaml);Compact(display string) stringremoves the separators. Ruling for docs/DECISIONS.md (update 24 4.3).- Known vector, computed independently for this brief: noise key bytes
0x00to0x1fand auth key bytes0x20to0x3fgiveF0CC-6QS6-RF5D-3BSH-CAZQ-PXMR-J8RA-AHK5-1HF0-9S02-1GW6-BW3J-HK90. services/host/testdata/noise/fingerprint.json(written bymake -C services/host recordings, compared byte for byte byTestFingerprintFileCurrent):{"format": 1, "vectors": [{"noise_public": "<hex>", "ed25519_public": "<hex>", "fingerprint": "<display form>"}, ...]}, first the known vector, then eight entries whose keys are SHA-256 offingerprint/noise/<i>andfingerprint/auth/<i>forifrom 1 to 8 (the function hashes bytes and needs no valid curve point). Field names avoid the keywords of the default gitleaks rulegeneric-api-key(for exampleauth,key,token,secret,api,access);make gitleaksmust stay green with the file committed. The Dart testfingerprint vectors matchand the paragraph onfingerprint.jsonintestdata/noise/README.mduse the same field names.
- Known vector, computed independently for this brief: noise key bytes
-
Dart package
bothy_noise.pubspec.yaml:name: bothy_noise,description,version: 0.0.0,publish_to: none,environmentwithsdkfrom the Dart version of the pinned Flutter release (caret range) andflutterwith the exact Flutter versionflutter --versionprints on the build host (stable, at least the release 15 2 names);dependencies: onlycryptography(newest 2.x, 2.9.0 per 24 5.2; on a newer one update 24 5.2);dev_dependencies:testandlints. Ruling for docs/DECISIONS.md: until the client app has its ownpubspec.yaml, theflutterline of this package is the Flutter pin that CI reads (flutter-version-file), because the rootMakefileruns the Dart gates withflutterand 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.yamlwith aworkspace:list, addpackages/bothy_noiseto it, setresolution: workspacein the package and track nopubspec.lockof its own; otherwise trackpackages/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:testand run withflutter test --coverage(the rootMakefile;flutter testaccepts a dependency ontestorflutter_test). Only if it refusespackage:test, switch the tests toflutter_testas a dev dependency, record each SDK package that OSV-Scanner then reports as unknown in aPackageOverridesentry with reason inpackages/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 lintpublic_member_api_docs(23 "Documentation in code"; M4-01 adds the gate test anddart docin the site).- Public library
package:bothy_noise/bothy_noise.dart: constantsnoiseProtocolName,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));NoiseExceptionwithNoiseError(handshakeFailed,decryptFailed,messageTooLong,nonceExhausted,broken,invalidState,invalidKey,invalidId); the sealed classTerminalFramewithStartFrame,DataFrame,ResizeFrame,CloseFrame,ExitFrame,ReplayBeginFrame,ReplayEndFrameand the getterisInput;Uint8List encodeFrame(TerminalFrame frame);TerminalFrame decodeFrame(List<int> bytes, FrameSender from)withenum FrameSender {device, host};FrameExceptionwithFrameError(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.dartexports onlyNkResponder(static Future<NkResponder> start({required SimpleKeyPair staticKeyPair, required List<int> sessionId, required List<int> hostId, SimpleKeyPair? ephemeral}),Future<Uint8List> readMessage1(List<int> message), the getterinitiatorEphemeralPublicKey,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 neitherpackage:flutternordart:io,dart:htmlor any networking package, so the same code runs in the apps and in the web console; tests may usedart:ioto read files.
- Workspace: if the base branch has M4-01's root
-
Noise state machine in Dart. Implement
CipherState,SymmetricStateand theNKhandshake of revision 34:his the protocol name itself (exactly 32 bytes),ck = h,MixHash(prologue), the pre-messageMixHash(rs); message 1e(MixHash(e.public)),es(MixKey(DH(e, rs))),EncryptAndHash(payload); message 2e,ee,EncryptAndHash(payload);Splitwith 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 datahduring the handshake, empty in transport; ciphertext and tag concatenated. Primitives:X25519,Chacha20.poly1305Aead(),Sha256andHmac.sha256()ofcryptography. The same limits, the zero-result rejection,brokenafter a failure and the nonce limit as in note 5.encrypttakes its nonce synchronously when called, so results belong to the call order (doc comment: send them in that order). -
Test vector files. Fetch
vectors/cacophony.txtfromhttps://github.com/centromere/cacophonyandtests/vector/noise-c-basic.txtfromhttps://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) asvectors/<name>.txt.gz, and the license asvectors/LICENSE.<project>. Ruling for docs/DECISIONS.md: compressed storage keeps the upstream content byte for byte belowrepo.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 andfingerprint.json. A license must be inquality/licenses/allowed.txtor a public-domain dedication such asUnlicense; test data is never shipped (licenses.md applies to shipped parts), and the README records the license. Any other license: stop withVerdict: BLOCKED <file and license>. Ruling for docs/DECISIONS.md. Loader rules (Go and Dart): the file is JSON with a listvectors; the protocol of an entry isprotocol_name, elsename, and only when neither existsNoise_<pattern>_<dh>_<cipher>_<hash>; selected are the entries equal toProtocolNamewithout a non-emptyinit_psk,resp_psk,init_psksorresp_psksand withoutfallback(noise-c-basic.txtalso holdsNoisePSK_NK_25519_ChaChaPoly_SHA256, a PSK handshake of an older Noise revision that plainNKcannot reproduce);resp_prologuedefaults toinit_prologue; messages alternate initiator and responder, the first two are the handshake, the rest transport; an entry with"fail": truemust fail at some message; unknown fields are ignored; every file must yield at least one selected entry, else the test fails. -
Recordings for TM-T02.
testdata/noise/recordings/go.jsonis written by Go,recordings/dart.jsonby Dart; each language compares its own file byte for byte with a fresh generation and replays the other's.UPDATE_RECORDINGS=1makes the test write instead of compare (Go:make -C services/host recordings; Dart:UPDATE_RECORDINGS=1 flutter test test/recordings_test.dartinpackages/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 rolesresponder-static,initiator-ephemeral,responder-ephemeral. Go uses session0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e0fand host0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e10, Dart session0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e11and host0192f4a0-7b1c-7b22-9d4f-5a6b7c8d9e12. Payload of message 1: the ASCII textEXAMPLE session token <scenario>; of message 2:{"v":1,"host_version":"0.0.0","profile":"shell"}. Scenarios in this order:open_shell(devicestart120x40, devicedatals\n, hostdataREADME.md\r\n, deviceresize100x30, devicedataexit\n, hostexit0);attach_replay(devicestart80x24, hostreplay_begin, hostdataearlier output\r\n, hostreplay_end, deviceclose);limits(devicestart65535x65535, devicedataof 65518 bytes with byteiequal toi mod 251, so the Noise message is exactly 65535 bytes, hostexit-1). -
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. -
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 blockservices/host/addinternal/fingerprint,testdata/noise/and.go-arch-lint.yml"maintained by hand, one component per package". 15 23.1: the rowbothy_noisenames the hash of the ephemeral key forclient_ephemeral_key_hashand the separate librarytesting.dartfor the responder. 23 3: row "Go module": the host keeps its.go-arch-lint.ymlby hand; load tests underperf/only for modules with performance targets (PERF_MODULES); row "Dart package": a package withouttest/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. -
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 forrelease-buildand the unsigned binaries without cgo (note 4); ID byte order (note 5);ErrBrokenafter 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 separatetesting.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 withfailfail; each file yields at least one entry; theNoisePSK_NK_25519_ChaChaPoly_SHA256entry of noise-c is not selected.TestVectorProvenance: every row oftestdata/noise/README.mdnames an existing.txt.gz, a 40-digit commit, an SPDX license with itsLICENSE.<project>file present, and a SHA-256 equal to that of the decompressed content.TestRecordingsCurrent: a fresh generation ofrecordings/go.jsonequals the file byte for byte (writes it withUPDATE_RECORDINGS=1).TestRecordingsTMT02Dart:recordings/dart.jsonreplayed 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 with0x01, a responder with another static key pair (SHA-256 ofnoise-recording/wrong-static), and a responder with session and host ID swapped in the prologue all failReadMessage1withErrHandshake; an initiator with the flipped session ID and one expecting another host key both failReadMessage2on 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: bytes0x00to0x1fgiveYw3NKWbEM2aRElRIu7JbT_QSpJxzLbLIq8G4WBvXEN0; 31 and 33 bytes giveErrInvalidKey.TestMessageLimits:Encryptof 65519 bytes gives exactly 65535 bytes and the peer decrypts it; 65520 bytes giveErrMessageTooLongand the next valid message still uses the expected nonce;Decryptof 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 failsWriteMessage1; a message 1 whose ephemeral key is all zero failsReadMessage1.TestBrokenAfterFailure(one flipped ciphertext bit givesErrDecrypt, the next valid messageErrBroken),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 payloadCANARY-7f3a91and the test keys, no error text of any failing call contains the canary, a key in hex or a key in base64url.TestPackagesImportNoNetwork:go/parserover the non-test files ofinternal/noise,internal/frameandinternal/fingerprint: no import ofnet,net/...oros/exec.FuzzReadMessage1andFuzzDecrypt: arbitrary input gives an error or a result, never a panic.
internal/frame:TestRoundTripfor every type and allowed direction;TestBigEndian(Start(0x0102, 0x0304)encodes as01 01 02 03 04);TestExitCodeSigned(-1 as05 ff ff ff ff,math.MinInt32,math.MaxInt32).TestDecodeUnknownType(0x00and0x08to0xff, both senders:ErrUnknownType);TestDecodeWrongDirection(start,resize,closefrom the host;exit,replay_begin,replay_endfrom the device);TestDecodeMalformed(empty inputErrEmpty;startandresizewith 3 or 5 body bytes or with 0 columns or rows;close,replay_begin,replay_endwith a body;exitwith 3 or 5 bytes;datawithout bytes).TestFrameSizeLimit(dataof 65518 bytes encodes and decodes; 65519 bytes giveErrTooLonginEncodeandDecode);TestMaxLenMatchesNoise;TestFrameIsInput(true only fordataandresize);TestErrorsCarryNoContent(a malformeddata-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 ofTestDecodeMalformed.
internal/fingerprint:TestEncodeRFC4648Vectors(the RFC 4648 section 10 inputs in the Crockford alphabet: empty to empty,ftoCR,fotoCSQG,footoCSQPY,foobtoCSQPYRG,foobatoCSQPYRK1,foobartoCSQPYRK1E8);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 cacophonyandTM-T01 noise-cwith the loader rules of note 10 (reading the.gzwithGZipCodec); theNoisePSK_NK_25519_ChaChaPoly_SHA256entry 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 ofTestRecordingsTMT02Negativeon both recordings),Dart recording is current(writes withUPDATE_RECORDINGS=1).handshake_test.dart: prologue bytes,uuidToBytescases,ephemeralKeyHashliteral, 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, includingisInput.fingerprint_test.dart: the RFC 4648 table, the known vector,fingerprint vectors match(every entry offingerprint.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 containsusage: 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 containsrelease-build: VERSION file says <file>, not 9.9.9);test_release_build_writes_one_binary_per_platform(the version from the file,OUTintmp_path): exactly the five names of note 4, each with the right format and machine: ELF (7f 45 4c 46) withe_machine0x3e(amd64) or0xb7(arm64), Mach-O 64-bit (cf fa ed fe) with cputype0x01000007or0x0100000c, PE (MZ) with machine0x8664.test_make_host_and_dart_lines.py(a temporary git repository with a copy of the rootMakefile, an emptytools/and fakeuv,flutteranddartexecutables first onPATH;MAKEFLAGSandMAKELEVELdropped from the environment):test_perf_skips_go_module_without_performance_targets(a trackedservices/host/go.modwithoutperf/:make perfexits 0 with the SKIP line);test_perf_still_requires_perf_dir_of_listed_module(a trackedservices/server/go.modwithoutperf/: exit 2 and the output contains M1-01's messageperf: 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_moduleandtest_flutter_covers_every_dart_package(both jobsgatesandsonar, conditions and version-file expressions of note 3);test_go_directives_agree(every trackedservices/*/go.modhas the samegoandtoolchainlines;services/host/go.modis tracked, so the test is not vacuous);test_flutter_pins_agree(every trackedpubspec.yamlwithenvironment.flutterhas one exactx.y.zand all are equal).test_bothy_noise_package.py:test_bothy_noise_dependencies(runtime dependencies exactlycryptography;publish_to: none);test_bothy_noise_has_no_flutter_network_or_storage(no file underlib/importspackage:flutter,dart:io,dart:htmlor a networking package).test_components.py: new caseservices/host/testdata/noise/recordings/go.jsonselectsappsandserver.- 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.pyif 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 --coveragemake -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.pymake arch,make codegen-checkmake fmt-check,make lint,make typecheck,make coverage,make licenses,make osv,make runners-check,make docs,make linkcheckmake 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_SHA256entry of the cacophony and noise-c files byte for byte; both files are stored with commit, license and content hash, andTestVectorProvenanceholds 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. -
FuzzDecodeexists, runs its seeds ingo test, and a local fuzz run is reported. -
make licensesandmake osvare green withgithub.com/flynn/noiseandcryptography; the report states version, release date and advisories of both. -
services/hosthasgo.mod,go.sum,VERSION,CHANGELOG.md,.golangci.yml,.go-arch-lint.ymland aMakefilewhoserelease-buildwrites the five binaries;make archpasses for it; Go coverage is at leastquality.coverage.go_min_percent. -
bothy_noiseimports no Flutter, nodart:ioand no networking inlib/, depends only oncryptography, exposes the responder only intesting.dart, and reachesquality.coverage.dart_min_percent. -
make perfno longer fails for the host module; a Dart package without tests or without coverage report failsmake testormake 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 checkis 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,servicesandpackageswritten 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.mdand RK-28 indocs/RISKS.mdupdated; ADR 0068 "Confirmation" names the tests;make adr-indexrun.- Rulings of Design note 14 recorded in
docs/DECISIONS.mdsection 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.mdand the rootCHANGELOG.mdentries 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. 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,cekhelper), content in errors and logs, and the library state. - 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 (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. - Review and red team verdicts and the findings fixed.
- What is not proven, for example: the binaries of
release-buildwere built but not run on macOS and Windows; the Dart code ran in the Dart VM, not in a browser. - Library state: the pinned versions of
github.com/flynn/noise,golang.org/x/cryptoandcryptography, 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 ofmake osvandgovulncheck; the commits and licenses of both vector files; the Flutter and Dart versions pinned; the fuzz runs with duration and result. - 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>.