diff --git a/.hypatia-ignore b/.hypatia-ignore index d3ab4a30..8981a259 100644 --- a/.hypatia-ignore +++ b/.hypatia-ignore @@ -39,3 +39,22 @@ cicd_rules/banned_language_file:panll/src/modules/BojModule.res cicd_rules/banned_language_file:cartridges/orchestrator-lsp-mcp/panels/src/Extension.res cicd_rules/banned_language_file:cartridges/orchestrator-lsp-mcp/panels/src/LanguageClient.res cicd_rules/banned_language_file:cartridges/orchestrator-lsp-mcp/panels/src/VscodeApi.res + +# ─── MCP cartridge adapters — TypeScript exemption (CLAUDE.md §TS Exemptions) ─ +# +# The "no new TypeScript" rule has 6 approved exemptions, all MCP cartridge +# adapters that use the TypeScript-native @anthropic/sdk. The exemption is +# documented in .claude/CLAUDE.md with full rationale, audit lineage +# (TS-elimination audit, 2026-05-02) and an unblock condition (AffineScript +# bindings to MCP). Mirroring it here so the Hypatia scanner stops +# flagging them as critical "banned_language_file" findings — the +# governance policy and the scanner exemption must agree. +# +# Adding new entries requires explicit user approval and an unblock +# condition (per CLAUDE.md). The 6 files below are the closed set. +cicd_rules/banned_language_file:cartridges/academic-workflow-mcp/adapter/mod.ts +cicd_rules/banned_language_file:cartridges/bofig-mcp/adapter/mod.ts +cicd_rules/banned_language_file:cartridges/ephapax-mcp/adapter/mod.ts +cicd_rules/banned_language_file:cartridges/fireflag-mcp/adapter/mod.ts +cicd_rules/banned_language_file:cartridges/hesiod-mcp/adapter/mod.ts +cicd_rules/banned_language_file:cartridges/sanctify-mcp/adapter/mod.ts diff --git a/cartridges/local-coord-mcp/abi/LocalCoord/Identity.idr b/cartridges/local-coord-mcp/abi/LocalCoord/Identity.idr new file mode 100644 index 00000000..61f815c9 --- /dev/null +++ b/cartridges/local-coord-mcp/abi/LocalCoord/Identity.idr @@ -0,0 +1,213 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +||| LocalCoord.Identity: Cryptographic peer identity for the local-coord +||| cartridge. +||| +||| Cartridge: local-coord-mcp +||| ADR: 0016 (mTLS + ed25519 federation stop-gap), Phase 1 — identity +||| foundation. No transport here; this module defines the *types* and +||| *FFI signatures* for an ed25519 keypair per peer, the public key as +||| federated identity, and the known_peers.toml entry shape. The Zig +||| implementation (cartridges/local-coord-mcp/adapter/) realises these +||| signatures with std.crypto.sign.Ed25519. +||| +||| Phase-1 scope (deliberate non-promises): +||| * Generates / loads ed25519 keypair material on disk. +||| * Exposes the public key for human export. +||| * Parses known_peers.toml. +||| * Does NOT sign or verify anything — Phase 2. +||| * Does NOT bind to any non-loopback address — Phase 3. +||| * Does NOT change the `FederationPolicy = LocalOnly` invariant from +||| SafeLocalCoord.idr. Identity material is necessary-but-not- +||| sufficient for federation; carrying a keypair does not enable it. +module LocalCoord.Identity + +import Data.List +import Data.Vect +import Data.Nat + +import LocalCoord.SafeLocalCoord + +%default total + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Key Material — Size-Indexed Byte Vectors +-- ═══════════════════════════════════════════════════════════════════════════ + +||| ed25519 public-key length in bytes. RFC 8032 §5.1.5. +public export +ed25519PubKeyBytes : Nat +ed25519PubKeyBytes = 32 + +||| ed25519 private-key length in bytes (seed form). RFC 8032 §5.1.5. +||| The expanded secret-scalar form is 64 bytes; we store the 32-byte +||| seed and derive the scalar at sign time. +public export +ed25519PrivKeyBytes : Nat +ed25519PrivKeyBytes = 32 + +||| ed25519 signature length in bytes (R || S). RFC 8032 §5.1.6. +public export +ed25519SigBytes : Nat +ed25519SigBytes = 64 + +||| A fixed-width byte vector. Used to enforce key-material sizes at +||| the type level — the only way to construct an `Ed25519PublicKey` +||| is via a value of `Bytes 32`, so any code holding one *knows* it +||| has 32 bytes without runtime checks. +public export +Bytes : Nat -> Type +Bytes n = Vect n Bits8 + +||| An ed25519 public key. Wrapper around `Bytes 32` so the type system +||| distinguishes pubkeys from arbitrary 32-byte blobs. +public export +record Ed25519PublicKey where + constructor MkPubKey + bytes : Bytes ed25519PubKeyBytes + +||| An ed25519 private key (seed form). NEVER crosses the FFI boundary +||| as a payload — the Zig adapter holds it in process memory and +||| references it via opaque handle. This type exists in the ABI only +||| to document the contract. +public export +record Ed25519PrivateKey where + constructor MkPrivKey + bytes : Bytes ed25519PrivKeyBytes + +||| An ed25519 signature over arbitrary bytes. +public export +record Ed25519Signature where + constructor MkSig + bytes : Bytes ed25519SigBytes + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Proof Obligation P-20: Ed25519 Key Material Well-Formedness +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- A public key value carries exactly `ed25519PubKeyBytes` bytes; a +-- signature value carries exactly `ed25519SigBytes`. This is enforced +-- *by construction* via `Vect n` — the only inhabitants of +-- `Bytes ed25519PubKeyBytes` are length-32 byte vectors, so any code +-- receiving an `Ed25519PublicKey` is statically guaranteed it has the +-- right length. No runtime size check is needed; no +-- malformed-key branch can exist in the protocol layer. +-- +-- The "proof" is the type itself: pattern-matching `MkPubKey bs` +-- recovers a `bs : Vect 32 Bits8`, whose index is fixed at the +-- definition site and cannot be shrunk or grown. +-- +-- Demonstration: a concrete zero-keyed pubkey is built-in-shape. + +||| Demonstration witness — a concrete pubkey value built from a +||| size-32 literal compiles iff the type's size invariant holds. +||| If this line fails to compile, P-20 has been broken. +public export +zeroPubKey : Ed25519PublicKey +zeroPubKey = MkPubKey (replicate ed25519PubKeyBytes 0) + +||| Same demonstration for signatures. +public export +zeroSig : Ed25519Signature +zeroSig = MkSig (replicate ed25519SigBytes 0) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Peer Identity = PeerId + Public Key +-- ═══════════════════════════════════════════════════════════════════════════ + +||| A peer's complete identity: the human-readable `PeerId` (from +||| SafeLocalCoord) plus the ed25519 public key that vouches for it. +||| The PeerId is for humans; the pubkey is for crypto. +public export +record PeerIdentity where + constructor MkPeerIdentity + peerId : PeerId + pubKey : Ed25519PublicKey + +||| Extract the display form of a peer identity. Goes through the +||| existing PeerId display — the pubkey is intentionally not rendered +||| here (use `pubKeyHex` for that, in a UI context). +public export +identityToString : PeerIdentity -> String +identityToString pi = peerIdToString (peerId pi) + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Known Peers (known_peers.toml entries) — Trust List +-- ═══════════════════════════════════════════════════════════════════════════ + +||| An entry in `~/.config/coord-tui/known_peers.toml`. Manual trust +||| list: peers we have explicitly agreed to federate with. No +||| discovery, no CA hierarchy — SSH known_hosts model. +||| +||| The `host` and `port` fields are Phase-3 wire material; Phase 1 +||| parses them but does not connect to them. +public export +record KnownPeer where + constructor MkKnownPeer + peerId : PeerId + pubKey : Ed25519PublicKey + host : String -- DNS name or literal IP — validated at use-site + port : Nat + +||| The maximum number of known peers a single host may trust. +||| Bound is generous; it exists to make `Vect`-based loading easy. +public export +maxKnownPeers : Nat +maxKnownPeers = 64 + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Federation Invariant Preservation +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- Crucially: this module adds *types*. It does not add a path from +-- those types to any non-loopback bind. The `FederationPolicy` value +-- in SafeLocalCoord remains `LocalOnly`, and `IsFederated LocalOnly` +-- remains uninhabited. Phase 3 will widen this — Phase 1 must not. + +||| Proof that having a PeerIdentity does not unlock federation. The +||| federation policy is still `LocalOnly`, and the negative proof +||| from `SafeLocalCoord.localOnlyNotFederated` carries through. +||| Discharge is structural: the LHS doesn't influence the RHS at all. +export +identityDoesNotEnableFederation + : (_ : PeerIdentity) + -> IsFederated coordFederationPolicy + -> Void +identityDoesNotEnableFederation _ x = localOnlyNotFederated x + +-- ═══════════════════════════════════════════════════════════════════════════ +-- C-ABI Contract (Phase 1) — documentation, not %foreign import +-- ═══════════════════════════════════════════════════════════════════════════ +-- +-- The Zig adapter exposes the following entry points. They are NOT +-- imported via `%foreign` here because the Idris2 ABI module's job is +-- to *type* the contract; the actual calls are made from Zig (intra- +-- cartridge) and from the Deno/Node bridge (over HTTP). This mirrors +-- the convention used in `SafeLocalCoord.idr`, which defines the type +-- envelope but doesn't import the Zig functions. +-- +-- int boj_coord_identity_init(const char *key_path); +-- Generates a fresh keypair if none exists at `key_path`, otherwise +-- loads the existing seed. Persists the seed (0600) on disk. Phase +-- 1 keys live at ~/.cache/coord-tui/peer.key. +-- Returns: 0 on success, non-zero error code otherwise. +-- +-- int boj_coord_identity_get_pubkey(uint8_t *out, size_t out_len); +-- Copies `ed25519PubKeyBytes` (32) bytes of the local public key +-- into `out`. The corresponding `Ed25519PublicKey` value can be +-- reconstructed from the bytes on the consumer side. +-- Returns: bytes written (== 32) on success, -1 if not initialised +-- or buffer too small. +-- +-- int boj_coord_identity_load_known_peers(const char *toml_path); +-- Parses `~/.config/coord-tui/known_peers.toml` (or supplied path) +-- into an in-process trust table of `KnownPeer` entries. Replaces +-- any previously loaded set (full reload). +-- Returns: number of entries loaded (>= 0) on success, -1 on error +-- (missing file is treated as zero entries, not an error). +-- +-- int boj_coord_identity_known_peer_count(void); +-- Returns the current count of loaded known peers. +-- +-- Phase 2 will add `boj_coord_envelope_sign` / `boj_coord_envelope_verify` +-- once `LocalCoord.Federation` lands the P-22 obligation. diff --git a/cartridges/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc b/cartridges/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc index 60c6ea80..fe8d1bc6 100644 --- a/cartridges/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc +++ b/cartridges/local-coord-mcp/abi/LocalCoord/PROOF-SCHEDULE.adoc @@ -43,6 +43,10 @@ toc::[] | P-17 | Echo-type formalisation of audit + summary + hash chain | P-15, P-16 | Phase 3 (deferred) | Agda (echo-types repo) | P-18 | Tropical-semiring model of TTL + trust | P-17 | Phase 3 (deferred) | Agda (EchoTropical) | P-19 | Cross-site primacy ceremony | P-15…P-18 | Phase 4 (deferred) | Idris2 + Agda +| P-20 | Ed25519 key-material well-formedness (pubkey ≡ 32B, sig ≡ 64B by construction) | — | ✅ done | Idris2 (`LocalCoord.Identity`) +| P-21 | Identity carries forward `FederationPolicy ≡ LocalOnly` (identity ≠ federation) | P-20, P-02 | ✅ done | Idris2 (`LocalCoord.Identity.identityDoesNotEnableFederation`) +| P-22 | Envelope signature soundness (sign-then-verify roundtrip on bytes) | P-20 | ADR-0016 Phase 5 | Idris2 (planned `LocalCoord.Federation`) +| P-23 | mTLS peer pinning matches `known_peers.toml` pubkey | P-20, P-22 | ADR-0016 Phase 5 | Idris2 + std.crypto axiomatised |=== Stage priorities: diff --git a/cartridges/local-coord-mcp/abi/local-coord-mcp.ipkg b/cartridges/local-coord-mcp/abi/local-coord-mcp.ipkg index 4d46f1bc..a9b1075b 100644 --- a/cartridges/local-coord-mcp/abi/local-coord-mcp.ipkg +++ b/cartridges/local-coord-mcp/abi/local-coord-mcp.ipkg @@ -9,4 +9,5 @@ brief = "Local-coord MCP cartridge — localhost multi-instance coordinatio sourcedir = "." modules = LocalCoord.SafeLocalCoord , LocalCoord.Protocol + , LocalCoord.Identity depends = base diff --git a/cartridges/local-coord-mcp/ffi/cartridge_shim.zig b/cartridges/local-coord-mcp/ffi/cartridge_shim.zig index 0e399dbb..5b0fac88 100644 --- a/cartridges/local-coord-mcp/ffi/cartridge_shim.zig +++ b/cartridges/local-coord-mcp/ffi/cartridge_shim.zig @@ -58,8 +58,14 @@ pub fn invokeArgsNull( /// Compare a C-NUL-terminated tool-name pointer against a Zig string /// literal. Caller must have already verified `tool_name` is non-null /// (usually via `invokeArgsNull`). +/// +/// Implementation note (CWE-704 fix, post-#146): uses +/// `std.mem.sliceTo(ptr, 0)` which scans the C string up to the first +/// NUL — no `@ptrCast` and no `[*:0]` re-typing. The earlier +/// `std.mem.spanZ` call was removed in Zig 0.14+ and would not +/// compile under the 0.15.1 CI pin. pub fn toolIs(tool_name: [*c]const u8, expected: []const u8) bool { - const s = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); + const s = std.mem.sliceTo(tool_name, 0); return std.mem.eql(u8, s, expected); } @@ -124,19 +130,19 @@ test "writeResult: empty body" { } test "toolIs: matches and rejects" { - const name: [*:0]const u8 = "foo"; - try std.testing.expect(toolIs(@ptrCast(name), "foo")); - try std.testing.expect(!toolIs(@ptrCast(name), "bar")); - try std.testing.expect(!toolIs(@ptrCast(name), "foobar")); - try std.testing.expect(!toolIs(@ptrCast(name), "fo")); + const name: [*c]const u8 = "foo"; + try std.testing.expect(toolIs(name, "foo")); + try std.testing.expect(!toolIs(name, "bar")); + try std.testing.expect(!toolIs(name, "foobar")); + try std.testing.expect(!toolIs(name, "fo")); } test "invokeArgsNull: detects each null slot" { var buf: [4]u8 = undefined; var len: usize = 4; - const name: [*:0]const u8 = "x"; - try std.testing.expect(!invokeArgsNull(@ptrCast(name), &buf, &len)); + const name: [*c]const u8 = "x"; + try std.testing.expect(!invokeArgsNull(name, &buf, &len)); try std.testing.expect(invokeArgsNull(null, &buf, &len)); - try std.testing.expect(invokeArgsNull(@ptrCast(name), null, &len)); - try std.testing.expect(invokeArgsNull(@ptrCast(name), &buf, null)); + try std.testing.expect(invokeArgsNull(name, null, &len)); + try std.testing.expect(invokeArgsNull(name, &buf, null)); } diff --git a/cartridges/local-coord-mcp/ffi/coord_identity.zig b/cartridges/local-coord-mcp/ffi/coord_identity.zig new file mode 100644 index 00000000..618652e3 --- /dev/null +++ b/cartridges/local-coord-mcp/ffi/coord_identity.zig @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// coord_identity.zig — Phase 1 (ADR-0016) ed25519 identity foundation. +// +// Realises the C-ABI contract documented in +// `cartridges/local-coord-mcp/abi/LocalCoord/Identity.idr`: +// +// * boj_coord_identity_init(key_path) -> int (0 ok) +// * boj_coord_identity_get_pubkey(out, out_len) -> int (bytes written, -1 err) +// * boj_coord_identity_load_known_peers(path) -> int (count, -1 err) +// * boj_coord_identity_known_peer_count() -> int +// +// Phase 1 scope: keypair generation, on-disk persistence (0600), pubkey +// export, and a minimal TOML-shaped parser for the trust list. NO +// signing, NO verification, NO network — those are Phase 2 / 3. + +const std = @import("std"); +const fs = std.fs; +const mem = std.mem; +const crypto = std.crypto; +const Ed25519 = crypto.sign.Ed25519; + +const PUBKEY_BYTES: usize = 32; +const SEED_BYTES: usize = 32; +const SIG_BYTES: usize = 64; +const MAX_KNOWN_PEERS: usize = 64; +const PEER_ID_MAX: usize = 32; +const HOST_MAX: usize = 256; + +// ═══════════════════════════════════════════════════════════════════ +// Global identity state (Phase 1 — singleton per process) +// ═══════════════════════════════════════════════════════════════════ + +const KnownPeer = struct { + peer_id: [PEER_ID_MAX]u8, + peer_id_len: u8, + pubkey: [PUBKEY_BYTES]u8, + host: [HOST_MAX]u8, + host_len: u16, + port: u16, +}; + +const IdentityState = struct { + initialised: bool = false, + key_pair: ?Ed25519.KeyPair = null, + known_peers: [MAX_KNOWN_PEERS]KnownPeer = undefined, + known_peer_count: usize = 0, +}; + +var state: IdentityState = .{}; +var state_mutex: std.Thread.Mutex = .{}; + +// ═══════════════════════════════════════════════════════════════════ +// Internal helpers +// ═══════════════════════════════════════════════════════════════════ + +fn cStrToSlice(ptr: [*:0]const u8) []const u8 { + return mem.span(ptr); +} + +fn ensureParentDir(path: []const u8) !void { + if (fs.path.dirname(path)) |dir| { + fs.makeDirAbsolute(dir) catch |e| switch (e) { + error.PathAlreadyExists => {}, + else => return e, + }; + } +} + +fn writeSeedFile(path: []const u8, seed: [SEED_BYTES]u8) !void { + try ensureParentDir(path); + const file = try fs.createFileAbsolute(path, .{ .mode = 0o600, .truncate = true }); + defer file.close(); + try file.writeAll(&seed); +} + +fn readSeedFile(path: []const u8) !?[SEED_BYTES]u8 { + const file = fs.openFileAbsolute(path, .{}) catch |e| switch (e) { + error.FileNotFound => return null, + else => return e, + }; + defer file.close(); + var buf: [SEED_BYTES]u8 = undefined; + const n = try file.readAll(&buf); + if (n != SEED_BYTES) return error.InvalidKeyFile; + return buf; +} + +// ═══════════════════════════════════════════════════════════════════ +// Public FFI surface — matches Identity.idr contract +// ═══════════════════════════════════════════════════════════════════ + +/// Initialise the identity store. Loads the keypair from `key_path` if +/// present; otherwise generates a fresh keypair and persists the seed +/// at that path (mode 0600). Idempotent: subsequent calls with the +/// same path are no-ops. +pub export fn boj_coord_identity_init(key_path: [*:0]const u8) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + if (state.initialised) return 0; + + const path = cStrToSlice(key_path); + + var seed: [SEED_BYTES]u8 = undefined; + if (readSeedFile(path) catch return -1) |existing| { + seed = existing; + } else { + crypto.random.bytes(&seed); + writeSeedFile(path, seed) catch return -2; + } + + // Ed25519.KeyPair.generateDeterministic is the Zig 0.15.x API for + // seed → keypair derivation. It can theoretically fail with + // IdentityElementError for adversarial seeds; not reachable for a + // CSPRNG-derived 32-byte input, but propagate the error code for + // honesty rather than `unreachable`. + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch return -3; + state.key_pair = kp; + state.initialised = true; + return 0; +} + +/// Copy the local ed25519 public key (32 bytes) into the caller's +/// buffer. Returns bytes written on success (== 32), -1 if not yet +/// initialised, -2 if buffer too small. +pub export fn boj_coord_identity_get_pubkey(out: [*]u8, out_len: usize) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + if (!state.initialised) return -1; + if (out_len < PUBKEY_BYTES) return -2; + const kp = state.key_pair orelse return -1; + // Ed25519.PublicKey holds a `.bytes: [32]u8` field directly. + @memcpy(out[0..PUBKEY_BYTES], &kp.public_key.bytes); + return @intCast(PUBKEY_BYTES); +} + +/// Load the known-peers trust list from `toml_path`. Replaces any +/// previously-loaded set on each call. Returns the number of entries +/// loaded (>= 0) or -1 on parse error. A missing file is treated as +/// zero entries (not an error) so the bus starts cleanly on first run. +pub export fn boj_coord_identity_load_known_peers(toml_path: [*:0]const u8) c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + + const path = cStrToSlice(toml_path); + const file = fs.openFileAbsolute(path, .{}) catch |e| switch (e) { + error.FileNotFound => { + state.known_peer_count = 0; + return 0; + }, + else => return -1, + }; + defer file.close(); + + var buf: [16384]u8 = undefined; + const n = file.readAll(&buf) catch return -1; + const text = buf[0..n]; + + state.known_peer_count = 0; + parseTomlPeers(text, &state.known_peers, &state.known_peer_count) catch return -1; + return @intCast(state.known_peer_count); +} + +/// Current number of loaded known peers. +pub export fn boj_coord_identity_known_peer_count() c_int { + state_mutex.lock(); + defer state_mutex.unlock(); + return @intCast(state.known_peer_count); +} + +// ═══════════════════════════════════════════════════════════════════ +// Minimal TOML-shaped parser +// ═══════════════════════════════════════════════════════════════════ +// +// Accepts the following shape, one or more times: +// +// [[peer]] +// id = "claude-7f3a" +// pubkey = "abcdef..." # 64 hex chars (32 bytes) +// host = "192.168.1.42" +// port = 7746 +// +// Comments start with '#'. Blank lines and unknown keys are ignored. +// All four fields are required per `[[peer]]` block; a block missing +// any required field is rejected at the end of that block. + +const ParseError = error{ Malformed, BadHex, TooManyPeers, MissingField }; + +const FieldFlags = packed struct { + id: bool = false, + pubkey: bool = false, + host: bool = false, + port: bool = false, +}; + +fn parseTomlPeers( + text: []const u8, + out: *[MAX_KNOWN_PEERS]KnownPeer, + out_count: *usize, +) ParseError!void { + var in_block = false; + var current: KnownPeer = std.mem.zeroes(KnownPeer); + var flags = FieldFlags{}; + + var lines = mem.splitScalar(u8, text, '\n'); + while (lines.next()) |raw_line| { + const line = trim(raw_line); + if (line.len == 0 or line[0] == '#') continue; + + if (mem.eql(u8, line, "[[peer]]")) { + if (in_block) { + try commitBlock(out, out_count, ¤t, &flags); + } + in_block = true; + current = std.mem.zeroes(KnownPeer); + flags = .{}; + continue; + } + + if (!in_block) return error.Malformed; // stray key=value before any [[peer]] + + const eq_idx = mem.indexOfScalar(u8, line, '=') orelse return error.Malformed; + const key = trim(line[0..eq_idx]); + const value = trim(line[eq_idx + 1 ..]); + + if (mem.eql(u8, key, "id")) { + const s = stripQuotes(value) orelse return error.Malformed; + if (s.len == 0 or s.len > PEER_ID_MAX) return error.Malformed; + @memcpy(current.peer_id[0..s.len], s); + current.peer_id_len = @intCast(s.len); + flags.id = true; + } else if (mem.eql(u8, key, "pubkey")) { + const s = stripQuotes(value) orelse return error.Malformed; + try hexDecode(s, current.pubkey[0..]); + flags.pubkey = true; + } else if (mem.eql(u8, key, "host")) { + const s = stripQuotes(value) orelse return error.Malformed; + if (s.len == 0 or s.len > HOST_MAX) return error.Malformed; + @memcpy(current.host[0..s.len], s); + current.host_len = @intCast(s.len); + flags.host = true; + } else if (mem.eql(u8, key, "port")) { + current.port = std.fmt.parseInt(u16, value, 10) catch return error.Malformed; + flags.port = true; + } + // Unknown keys: silently ignored (forward-compat). + } + if (in_block) { + try commitBlock(out, out_count, ¤t, &flags); + } +} + +fn commitBlock( + out: *[MAX_KNOWN_PEERS]KnownPeer, + out_count: *usize, + current: *const KnownPeer, + flags: *const FieldFlags, +) ParseError!void { + if (!(flags.id and flags.pubkey and flags.host and flags.port)) { + return error.MissingField; + } + if (out_count.* >= MAX_KNOWN_PEERS) return error.TooManyPeers; + out[out_count.*] = current.*; + out_count.* += 1; +} + +fn trim(s: []const u8) []const u8 { + var start: usize = 0; + var end: usize = s.len; + while (start < end and isSpace(s[start])) start += 1; + while (end > start and isSpace(s[end - 1])) end -= 1; + return s[start..end]; +} + +fn isSpace(c: u8) bool { + return c == ' ' or c == '\t' or c == '\r'; +} + +fn stripQuotes(s: []const u8) ?[]const u8 { + if (s.len < 2) return null; + if (s[0] != '"' or s[s.len - 1] != '"') return null; + return s[1 .. s.len - 1]; +} + +fn hexDecode(hex: []const u8, out: []u8) ParseError!void { + if (hex.len != out.len * 2) return error.BadHex; + var i: usize = 0; + while (i < out.len) : (i += 1) { + const hi = hexNibble(hex[2 * i]) orelse return error.BadHex; + const lo = hexNibble(hex[2 * i + 1]) orelse return error.BadHex; + out[i] = (hi << 4) | lo; + } +} + +fn hexNibble(c: u8) ?u8 { + return switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Test-only helpers — allow Zig unit tests to inspect internal state +// without going through the C-ABI surface. +// ═══════════════════════════════════════════════════════════════════ + +pub fn testResetState() void { + state_mutex.lock(); + defer state_mutex.unlock(); + state = .{}; +} + +pub fn testKnownPeerAt(index: usize) ?KnownPeer { + state_mutex.lock(); + defer state_mutex.unlock(); + if (index >= state.known_peer_count) return null; + return state.known_peers[index]; +} + +// ═══════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════ + +test "hexDecode roundtrip on a known pubkey-shaped value" { + var out: [PUBKEY_BYTES]u8 = undefined; + try hexDecode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", &out); + try std.testing.expectEqual(@as(u8, 0x01), out[0]); + try std.testing.expectEqual(@as(u8, 0xef), out[31]); +} + +test "hexDecode rejects wrong-length input" { + var out: [4]u8 = undefined; + try std.testing.expectError(error.BadHex, hexDecode("aabbcc", &out)); // 6 chars, need 8 +} + +test "hexDecode rejects non-hex characters" { + var out: [2]u8 = undefined; + try std.testing.expectError(error.BadHex, hexDecode("ZZAA", &out)); +} + +test "parseTomlPeers handles a single complete block" { + testResetState(); + const toml = + \\[[peer]] + \\id = "claude-7f3a" + \\pubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + \\host = "192.168.1.42" + \\port = 7746 + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try parseTomlPeers(toml, &peers, &count); + try std.testing.expectEqual(@as(usize, 1), count); + try std.testing.expectEqual(@as(u16, 7746), peers[0].port); + try std.testing.expectEqualStrings("claude-7f3a", peers[0].peer_id[0..peers[0].peer_id_len]); +} + +test "parseTomlPeers handles multiple blocks and comments" { + testResetState(); + const toml = + \\# trust list + \\[[peer]] + \\id = "alice" + \\pubkey = "0000000000000000000000000000000000000000000000000000000000000001" + \\host = "alice.local" + \\port = 7746 + \\ + \\[[peer]] + \\id = "bob" + \\pubkey = "0000000000000000000000000000000000000000000000000000000000000002" + \\host = "bob.local" + \\port = 7747 + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try parseTomlPeers(toml, &peers, &count); + try std.testing.expectEqual(@as(usize, 2), count); + try std.testing.expectEqual(@as(u16, 7747), peers[1].port); +} + +test "parseTomlPeers rejects a block missing fields" { + const toml = + \\[[peer]] + \\id = "incomplete" + \\host = "x" + ; + var peers: [MAX_KNOWN_PEERS]KnownPeer = undefined; + var count: usize = 0; + try std.testing.expectError(error.MissingField, parseTomlPeers(toml, &peers, &count)); +} + +test "FFI: identity init generates and persists, second init no-ops" { + testResetState(); + const tmp_path = "/tmp/boj-coord-test-identity.key"; + // Clean any previous state + fs.deleteFileAbsolute(tmp_path) catch {}; + defer fs.deleteFileAbsolute(tmp_path) catch {}; + + // Zig string literals already carry a `:0` sentinel, so `.ptr` + // coerces directly to `[*:0]const u8`. No @ptrCast needed. + const path_z: [:0]const u8 = tmp_path; + const rc1 = boj_coord_identity_init(path_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc1); + + var pubkey1: [PUBKEY_BYTES]u8 = undefined; + const n1 = boj_coord_identity_get_pubkey(&pubkey1, PUBKEY_BYTES); + try std.testing.expectEqual(@as(c_int, @intCast(PUBKEY_BYTES)), n1); + + // Re-init: idempotent on the same process state. + const rc2 = boj_coord_identity_init(path_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc2); + + var pubkey2: [PUBKEY_BYTES]u8 = undefined; + _ = boj_coord_identity_get_pubkey(&pubkey2, PUBKEY_BYTES); + try std.testing.expectEqualSlices(u8, &pubkey1, &pubkey2); +} + +test "FFI: get_pubkey before init returns -1" { + testResetState(); + var pubkey: [PUBKEY_BYTES]u8 = undefined; + try std.testing.expectEqual(@as(c_int, -1), boj_coord_identity_get_pubkey(&pubkey, PUBKEY_BYTES)); +} + +test "FFI: load_known_peers on missing file returns 0" { + testResetState(); + const missing_z: [:0]const u8 = "/tmp/boj-coord-test-no-such-known-peers.toml"; + const rc = boj_coord_identity_load_known_peers(missing_z.ptr); + try std.testing.expectEqual(@as(c_int, 0), rc); + try std.testing.expectEqual(@as(c_int, 0), boj_coord_identity_known_peer_count()); +} + +// RFC 8032 §7.1 TEST 1 — the canonical ed25519 reference vector. +// The matching test in coord-tui/src/main.rs pins the same vector, +// so if both this test and that test pass, the Rust and Zig +// derivations agree with the spec — and therefore with each other +// — across the shared 32-byte seed-file format. +// +// SEED: 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60 +// PUBKEY: d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a + +test "RFC 8032 §7.1 TEST 1 — seed derives the canonical pubkey" { + testResetState(); + const seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + const expect_hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + var seed: [SEED_BYTES]u8 = undefined; + try hexDecode(seed_hex, &seed); + + const tmp_path = "/tmp/boj-coord-test-rfc8032.key"; + fs.deleteFileAbsolute(tmp_path) catch {}; + defer fs.deleteFileAbsolute(tmp_path) catch {}; + try writeSeedFile(tmp_path, seed); + + const path_z: [:0]const u8 = tmp_path; + try std.testing.expectEqual(@as(c_int, 0), boj_coord_identity_init(path_z.ptr)); + + var pubkey: [PUBKEY_BYTES]u8 = undefined; + try std.testing.expectEqual( + @as(c_int, @intCast(PUBKEY_BYTES)), + boj_coord_identity_get_pubkey(&pubkey, PUBKEY_BYTES), + ); + + var expect_bytes: [PUBKEY_BYTES]u8 = undefined; + try hexDecode(expect_hex, &expect_bytes); + try std.testing.expectEqualSlices(u8, &expect_bytes, &pubkey); +} diff --git a/cartridges/local-coord-mcp/ffi/local_coord_ffi.zig b/cartridges/local-coord-mcp/ffi/local_coord_ffi.zig index 9ae64b58..631908f1 100644 --- a/cartridges/local-coord-mcp/ffi/local_coord_ffi.zig +++ b/cartridges/local-coord-mcp/ffi/local_coord_ffi.zig @@ -18,6 +18,14 @@ const std = @import("std"); const dur = @import("coord_durability.zig"); +// ADR-0016 Phase 1: pull in coord_identity so its `pub export fn` +// symbols are part of the shared library. The module does not need to +// be referenced directly from this file — the import side-effect is +// to surface the FFI exports for `boj_coord_identity_*`. +comptime { + _ = @import("coord_identity.zig"); +} + // ═══════════════════════════════════════════════════════════════════════ // Constants (must match SafeLocalCoord.idr) // ═══════════════════════════════════════════════════════════════════════ @@ -3279,11 +3287,13 @@ export fn boj_cartridge_invoke( in_out_len.* = 64; // hint a minimum useful size return shim.RC_BUFFER_TOO_SMALL; } - const tool = std.mem.span(@as([*:0]const u8, @ptrCast(tool_name))); - const args: []const u8 = if (json_args != null) - std.mem.span(@as([*:0]const u8, @ptrCast(json_args))) - else - "{}"; + // CWE-704 fix (post-#146): std.mem.sliceTo(ptr, 0) reads the C string + // up to the first NUL without an `@ptrCast` and without the + // `std.mem.spanZ` that no longer exists in Zig 0.14+. The optional-payload + // capture `if (json_args != null) |ja|` was also invalid for [*c] + // pointers — those are null-checked with `== null`, not unwrapped. + const tool = std.mem.sliceTo(tool_name, 0); + const args: []const u8 = if (json_args == null) "{}" else std.mem.sliceTo(json_args, 0); const result = ci_dispatch(tool, args, out_buf[0..cap], std.heap.page_allocator); in_out_len.* = result.written; return result.rc; diff --git a/coord-tui/Cargo.lock b/coord-tui/Cargo.lock index de2eb569..febed5ac 100644 --- a/coord-tui/Cargo.lock +++ b/coord-tui/Cargo.lock @@ -100,6 +100,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -239,6 +245,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -254,6 +266,7 @@ version = "0.1.0" dependencies = [ "clap", "crossterm", + "ed25519-dalek", "ratatui", "serde_json", "ureq", @@ -325,6 +338,33 @@ dependencies = [ "phf", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -365,6 +405,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -426,6 +476,30 @@ dependencies = [ "litrs", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -467,6 +541,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1177,6 +1257,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1252,6 +1342,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "ratatui" @@ -1562,6 +1655,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1586,6 +1688,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/coord-tui/Cargo.toml b/coord-tui/Cargo.toml index 66c832a1..ba5e6447 100644 --- a/coord-tui/Cargo.toml +++ b/coord-tui/Cargo.toml @@ -18,3 +18,4 @@ crossterm = { version = "0.29", features = ["event-stream"] } ureq = "2" serde_json = "1" clap = { version = "4", features = ["derive", "env"] } +ed25519-dalek = "2.2.0" diff --git a/coord-tui/src/main.rs b/coord-tui/src/main.rs index b3a5c049..84d59c17 100644 --- a/coord-tui/src/main.rs +++ b/coord-tui/src/main.rs @@ -54,6 +54,19 @@ struct Cli { /// Used by shell hooks triggered on tool launch. #[arg(long)] id: bool, + + /// Print this peer's ed25519 public key as 64 hex characters and exit. + /// Reads (or creates on first run) the seed file at `--key-path`. Used + /// for manual peer-key exchange in ADR-0016 federation. The file is + /// the shared identity contract between coord-tui and the Zig adapter. + #[arg(long)] + print_pubkey: bool, + + /// Path to the ed25519 seed file. Default: $XDG_CACHE_HOME/coord-tui/peer.key + /// or ~/.cache/coord-tui/peer.key. The Zig adapter uses the same path + /// when its `boj_coord_identity_init` is called. + #[arg(long, env = "BOJ_COORD_KEY_PATH")] + key_path: Option, } // ─── HTTP ───────────────────────────────────────────────────────────────────── @@ -595,6 +608,102 @@ fn centered(pct_w: u16, h: u16, r: Rect) -> Rect { Rect::new(x, y, w, h) } +// ─── ed25519 identity (ADR-0016 Phase 1) ───────────────────────────────────── +// +// Reads (or creates on first run) the 32-byte seed file and derives the +// ed25519 public key. The file format is the shared identity contract +// between coord-tui and the Zig adapter — either can be the first to +// create it; the second one will load the existing seed unchanged. + +fn default_key_path() -> std::path::PathBuf { + if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + if !xdg.is_empty() { + return std::path::Path::new(&xdg).join("coord-tui").join("peer.key"); + } + } + let home = std::env::var("HOME").unwrap_or_default(); + std::path::Path::new(&home).join(".cache").join("coord-tui").join("peer.key") +} + +fn read_or_create_seed(path: &std::path::Path) -> Result<[u8; 32], String> { + if path.exists() { + let bytes = std::fs::read(path) + .map_err(|e| format!("read seed: {}", e))?; + if bytes.len() != 32 { + return Err(format!("seed file is {} bytes, expected 32", bytes.len())); + } + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes); + return Ok(seed); + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create parent dir {}: {}", parent.display(), e))?; + } + + // Generate a fresh 32-byte seed by reading /dev/urandom directly. + // This avoids pulling in a `rand` dep just for this one call and + // matches what the Zig adapter does (std.crypto.random.bytes on + // Linux ultimately calls getrandom(2) / /dev/urandom). The seed is + // a private RFC 8032 ed25519 seed; ed25519-dalek will derive the + // expanded secret scalar at sign time. + let seed = read_urandom_32()?; + write_seed_0600(path, &seed)?; + Ok(seed) +} + +#[cfg(unix)] +fn read_urandom_32() -> Result<[u8; 32], String> { + use std::io::Read; + let mut f = std::fs::File::open("/dev/urandom") + .map_err(|e| format!("open /dev/urandom: {}", e))?; + let mut seed = [0u8; 32]; + f.read_exact(&mut seed) + .map_err(|e| format!("read /dev/urandom: {}", e))?; + Ok(seed) +} + +#[cfg(not(unix))] +fn read_urandom_32() -> Result<[u8; 32], String> { + Err("Phase 1 supports Unix-like platforms only (needs /dev/urandom).".into()) +} + +#[cfg(unix)] +fn write_seed_0600(path: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> { + use std::os::unix::fs::OpenOptionsExt; + let mut f = std::fs::OpenOptions::new() + .write(true).create_new(true).mode(0o600).open(path) + .map_err(|e| format!("create seed {}: {}", path.display(), e))?; + std::io::Write::write_all(&mut f, seed) + .map_err(|e| format!("write seed: {}", e)) +} + +#[cfg(not(unix))] +fn write_seed_0600(path: &std::path::Path, seed: &[u8; 32]) -> Result<(), String> { + // Phase 1 supports Linux/macOS only — non-Unix platforms cannot + // restrict the mode at file-create time. Refuse rather than write + // a world-readable seed. + let _ = (path, seed); + Err("Phase 1 supports Unix-like platforms only (mode 0600 required for the seed file).".into()) +} + +fn print_pubkey(override_path: Option<&str>) -> Result { + let path = match override_path { + Some(p) => std::path::PathBuf::from(p), + None => default_key_path(), + }; + let seed = read_or_create_seed(&path)?; + use ed25519_dalek::SigningKey; + let signing = SigningKey::from_bytes(&seed); + let pubkey = signing.verifying_key().to_bytes(); + let mut hex = String::with_capacity(64); + for b in pubkey { + hex.push_str(&format!("{:02x}", b)); + } + Ok(hex) +} + // ─── Context detection ──────────────────────────────────────────────────────── fn detect_context() -> String { @@ -642,6 +751,19 @@ fn main() -> io::Result<()> { let cli = Cli::parse(); let context = cli.context.unwrap_or_else(detect_context); + if cli.print_pubkey { + match print_pubkey(cli.key_path.as_deref()) { + Ok(hex) => { + println!("{}", hex); + return Ok(()); + } + Err(e) => { + eprintln!("coord-tui --print-pubkey: {}", e); + std::process::exit(1); + } + } + } + if cli.id { silent_register(&cli.url, &cli.kind, &context); return Ok(()); @@ -731,3 +853,92 @@ fn main() -> io::Result<()> { execute!(terminal.backend_mut(), LeaveAlternateScreen)?; Ok(()) } + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn tmp_path(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + // Disambiguate across parallel tests in this module. + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + p.push(format!("coord-tui-test-{}-{}-{}", std::process::id(), nanos, name)); + p + } + + fn hex_decode(s: &str) -> Vec { + (0..s.len()).step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + /// RFC 8032 §7.1 TEST 1 — the canonical ed25519 reference vector. + /// Pinning this seed → pubkey mapping proves the Rust derivation + /// matches the spec. The Zig adapter pins the same vector in + /// `coord_identity.zig`; if both match RFC 8032, they match each + /// other — which is the cross-implementation consistency guarantee + /// for the shared seed-file format. + const RFC8032_TEST1_SEED_HEX: &str = + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; + const RFC8032_TEST1_PUBKEY_HEX: &str = + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + #[test] + fn print_pubkey_matches_rfc8032_test1() { + let path = tmp_path("rfc8032.key"); + let _ = std::fs::remove_file(&path); + std::fs::write(&path, hex_decode(RFC8032_TEST1_SEED_HEX)).unwrap(); + + let hex = print_pubkey(Some(path.to_str().unwrap())).unwrap(); + assert_eq!(hex, RFC8032_TEST1_PUBKEY_HEX); + + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn print_pubkey_roundtrip_is_deterministic() { + // Two reads of the same seed file produce the same pubkey. + let path = tmp_path("roundtrip.key"); + let _ = std::fs::remove_file(&path); + + let first = print_pubkey(Some(path.to_str().unwrap())).unwrap(); + let second = print_pubkey(Some(path.to_str().unwrap())).unwrap(); + assert_eq!(first, second); + assert_eq!(first.len(), 64); + assert!(first.chars().all(|c| c.is_ascii_hexdigit())); + + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn print_pubkey_rejects_wrong_length_seed() { + let path = tmp_path("short.key"); + let _ = std::fs::remove_file(&path); + std::fs::write(&path, b"only-five").unwrap(); + + let err = print_pubkey(Some(path.to_str().unwrap())).unwrap_err(); + assert!(err.contains("expected 32"), "unexpected error: {}", err); + + std::fs::remove_file(&path).unwrap(); + } + + #[cfg(unix)] + #[test] + fn fresh_seed_file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let path = tmp_path("perms.key"); + let _ = std::fs::remove_file(&path); + + let _ = print_pubkey(Some(path.to_str().unwrap())).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "seed file mode = {:o}, expected 0600", mode); + + std::fs::remove_file(&path).unwrap(); + } +} diff --git a/docs/backend-assurance/README.md b/docs/backend-assurance/README.md index 2439c6f1..da2b3891 100644 --- a/docs/backend-assurance/README.md +++ b/docs/backend-assurance/README.md @@ -1,3 +1,6 @@ + + + # Backend-Assurance Harness External evidence for the class-(J) `believe_me` axioms in diff --git a/docs/backend-assurance/prim__eqChar.md b/docs/backend-assurance/prim__eqChar.md index 76e3f99a..3a01df42 100644 --- a/docs/backend-assurance/prim__eqChar.md +++ b/docs/backend-assurance/prim__eqChar.md @@ -1,3 +1,6 @@ + + + # Backend-Assurance: `prim__eqChar` Trusted-extraction validation for the two class-(J) axioms over diff --git a/docs/backend-assurance/prim__strAppend.md b/docs/backend-assurance/prim__strAppend.md index e3cdd3f0..ae7241e9 100644 --- a/docs/backend-assurance/prim__strAppend.md +++ b/docs/backend-assurance/prim__strAppend.md @@ -1,3 +1,6 @@ + + + # Backend-Assurance: `prim__strAppend` Trusted-extraction validation for the class-(J) axiom over Idris2's diff --git a/docs/backend-assurance/prim__strSubstr.md b/docs/backend-assurance/prim__strSubstr.md index 7e677cee..dbf10c80 100644 --- a/docs/backend-assurance/prim__strSubstr.md +++ b/docs/backend-assurance/prim__strSubstr.md @@ -1,3 +1,6 @@ + + + # Backend-Assurance: `prim__strSubstr` Trusted-extraction validation for the class-(J) axiom over Idris2's diff --git a/docs/backend-assurance/prim__strToCharList.md b/docs/backend-assurance/prim__strToCharList.md index eda66fa7..36ccc331 100644 --- a/docs/backend-assurance/prim__strToCharList.md +++ b/docs/backend-assurance/prim__strToCharList.md @@ -1,3 +1,6 @@ + + + # Backend-Assurance: `prim__strToCharList` Trusted-extraction validation for the class-(J) axiom over Idris2's diff --git a/elixir/test/backend_assurance/prim_str_append_test.exs b/elixir/test/backend_assurance/prim_str_append_test.exs index cc501cda..ae8ab05e 100644 --- a/elixir/test/backend_assurance/prim_str_append_test.exs +++ b/elixir/test/backend_assurance/prim_str_append_test.exs @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Backend-assurance harness for `prim__strAppend`. diff --git a/elixir/test/backend_assurance/prim_str_substr_test.exs b/elixir/test/backend_assurance/prim_str_substr_test.exs index 7bef7c8b..8ba467df 100644 --- a/elixir/test/backend_assurance/prim_str_substr_test.exs +++ b/elixir/test/backend_assurance/prim_str_substr_test.exs @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Backend-assurance harness for `prim__strSubstr`. diff --git a/elixir/test/backend_assurance/prim_str_to_char_list_test.exs b/elixir/test/backend_assurance/prim_str_to_char_list_test.exs index ec9440ef..89bb5ce8 100644 --- a/elixir/test/backend_assurance/prim_str_to_char_list_test.exs +++ b/elixir/test/backend_assurance/prim_str_to_char_list_test.exs @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: PMPL-1.0-or-later +# SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # # Backend-assurance harness for `prim__strToCharList`. diff --git a/tests/backend-assurance/README.md b/tests/backend-assurance/README.md index 61ad29cc..bb2d9dc3 100644 --- a/tests/backend-assurance/README.md +++ b/tests/backend-assurance/README.md @@ -1,3 +1,6 @@ + + + # `tests/backend-assurance/` — pointer The runnable property-test harness for the backend-assurance campaign