From b02cf0c9fb42eb5862f27c7eeda1af9d45033d12 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 20 Oct 2026 18:05:12 +0800 Subject: [PATCH] Add native Windows ARM64 pairing compatibility experiment Wheel-only dependency resolution cannot establish whether Python/Go pairing works on Windows ARM64. Add an isolated Noise PSK probe and run it in the existing native CI job, reusing its OpenSSL setup and allowing Python source builds. Keep experiment dependencies and Go 1.26 setup separate from the product. Verify native interpreter/toolchain architecture and retain environment, build and test evidence as an artifact. The portable harness covers both roles and 13 compatibility/negative-test groups. Validation: isolated Linux runner and all 13 test groups pass; go vet, Python syntax/format checks and actionlint pass. Native Windows ARM64 execution is pending CI. This experiment does not select production authentication dependencies or change package behavior. --- .github/workflows/ci.yml | 21 ++ experiments/pairing-noise/.gitignore | 1 + experiments/pairing-noise/README.md | 68 ++++ experiments/pairing-noise/check.py | 391 +++++++++++++++++++++ experiments/pairing-noise/go.mod | 10 + experiments/pairing-noise/go.sum | 19 + experiments/pairing-noise/peer.go | 136 +++++++ experiments/pairing-noise/requirements.txt | 4 + experiments/pairing-noise/run.py | 91 +++++ 9 files changed, 741 insertions(+) create mode 100644 experiments/pairing-noise/.gitignore create mode 100644 experiments/pairing-noise/README.md create mode 100644 experiments/pairing-noise/check.py create mode 100644 experiments/pairing-noise/go.mod create mode 100644 experiments/pairing-noise/go.sum create mode 100644 experiments/pairing-noise/peer.go create mode 100644 experiments/pairing-noise/requirements.txt create mode 100644 experiments/pairing-noise/run.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d062a0..d3b10b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,6 +154,27 @@ jobs: GOCACHE: ${{ runner.temp }}/go-build run: nox -s ${{ matrix.nox_session }} + - name: Set up Go for pairing experiment + if: ${{ !cancelled() && matrix.platform_id == 'windows-arm64' }} + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: "1.26.x" + cache: false + + - name: Run isolated pairing experiment on Windows ARM64 + if: ${{ !cancelled() && matrix.platform_id == 'windows-arm64' }} + env: + GOCACHE: ${{ runner.temp }}/pairing-go-build + run: python experiments/pairing-noise/run.py --require-windows-arm64 --output-dir .ci/pairing-noise + + - name: Upload Windows ARM64 pairing evidence + if: ${{ always() && matrix.platform_id == 'windows-arm64' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pairing-noise-windows-arm64 + path: .ci/pairing-noise/ + if-no-files-found: warn + website: name: Website runs-on: ubuntu-latest diff --git a/experiments/pairing-noise/.gitignore b/experiments/pairing-noise/.gitignore new file mode 100644 index 0000000..6628455 --- /dev/null +++ b/experiments/pairing-noise/.gitignore @@ -0,0 +1 @@ +/results/ diff --git a/experiments/pairing-noise/README.md b/experiments/pairing-noise/README.md new file mode 100644 index 0000000..c77ad55 --- /dev/null +++ b/experiments/pairing-noise/README.md @@ -0,0 +1,68 @@ +# Python/Go pairing compatibility experiment + +This isolated probe tests `Noise_NNpsk0_25519_ChaChaPoly_SHA256` with a fresh +256-bit pre-shared secret. It does not implement Arbiter enrollment or select +production dependencies. No real identity, account, credential or listener is +used. The module and virtual environment are separate from the product. + +## Run + +Use CPython 3.10–3.14 and Go 1.26 or newer: + +```sh +python experiments/pairing-noise/run.py +``` + +The runner creates an isolated `.venv`, installs exact versions from +`requirements.txt`, builds the pinned Go peer with CGo disabled, runs `go vet` +and executes all 13 test groups. Logs, environment/dependency versions and +`test-results.json` go into the ignored `results/` directory. Select another +location with `--output-dir PATH`. Initial installation needs network access. + +The Windows ARM64 CI step reuses the existing OpenSSL setup and permits source +builds of Python dependencies. It checks CPython 3.13 on that native runner; +`--require-windows-arm64` rejects an x64 interpreter or Go target. The experiment +uses a separate Go setup after product tests, leaving their toolchain intact. +CI uploads logs even if installation, compilation or tests fail. + +There is no wheel-only installation requirement: Arbiter already depends on +`cryptography`, and its Windows ARM64 CI provisions OpenSSL for dependency +builds. A missing wheel alone is not evidence that Arbiter drops that platform. +This probe must actually run there before claiming native compatibility. + +## Scope of the checks + +Both Python/Go role combinations check matching handshake hashes and directional +key fingerprints, explicit encrypted confirmations, wrong secrets/context, +tampered ephemeral keys and handshake payloads, malformed/truncated messages, +transport tampering/replay, missing/wrong-length PSKs, fresh sessions, and +rejection of an old session's confirmation after a replayed first message. + +The PSK is provided through a trusted local test-control pipe to configure the +Go peer. That pipe is not the simulated network: only Noise output bytes are +treated as network messages. Tests check that those messages do not contain +the raw secret or its Base64url form. Actual keys/secrets are not logged. +The probe uses synthetic context and credential payloads, not an adopted Arbiter +wire format. Key fingerprint extraction is test-only. + +Noise alone does not reject a replayed first message. Real enrollment still +needs owner approval, bounded pending-state/attempts, expiry, binding and +transactional one-time completion. Missing PSKs must fail closed: the Go +library can otherwise configure unauthenticated NN. Failed handshake state is +discarded rather than reused. + +These tests demonstrate interoperability only. The Python wrapper's tested +release is from 2020; its README states explicit Linux support only and leaves +peer review/side-channel work unfinished. Production library assessment, +certificate profiles, durable identity binding, full platform execution and +independent security review remain open. This experiment's crypto module needs +Go 1.26; it does not change Arbiter's Go 1.24 requirement. + +No package release note is needed: this changes experimental evidence and CI, +with no change to a published package or product behavior. + +Primary sources: + +- [Noise PSK specification](https://noiseprotocol.org/noise.html#pre-shared-symmetric-keys) +- [Python candidate at the tested tag](https://github.com/plizonczyk/noiseprotocol/tree/v0.3.1) +- [Go candidate at the tested tag](https://github.com/flynn/noise/tree/v1.1.0) diff --git a/experiments/pairing-noise/check.py b/experiments/pairing-noise/check.py new file mode 100644 index 0000000..4738627 --- /dev/null +++ b/experiments/pairing-noise/check.py @@ -0,0 +1,391 @@ +"""Run the isolated Python/Go Noise probe. No network listener or real credentials.""" + +import base64 +from contextlib import contextmanager +import hashlib +import importlib.metadata +import io +import json +import os +from pathlib import Path +import platform +from queue import Empty, Queue +import secrets +import subprocess +import sys +from threading import Thread +import unittest + +from cryptography.exceptions import InvalidTag +from noise.connection import NoiseConnection +from noise.exceptions import NoiseInvalidMessage, NoisePSKError, NoiseValueError + +ROOT = Path(__file__).resolve().parent +OUTPUT = Path(os.environ.get("PAIRING_POC_OUTPUT", ROOT / "results")) +PEER = ROOT / "build" / ("peer.exe" if os.name == "nt" else "peer") +PROFILE = b"Noise_NNpsk0_25519_ChaChaPoly_SHA256" + + +def b64(data): + return base64.b64encode(data).decode("ascii") + + +class Rejected(Exception): + pass + + +class GoPeer: + def __init__(self, initiator, psk, prologue): + self.p = subprocess.Popen( + [str(PEER)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + # Windows select() cannot poll subprocess pipes. A reader thread gives + # both platforms the same bounded response wait and EOF handling. + self.responses = Queue() + self.reader = Thread(target=self._read_responses, daemon=True) + self.reader.start() + try: + self.call("init", initiator=initiator, psk=psk, prologue=prologue) + except BaseException: + self.close() + raise + + def _read_responses(self): + try: + for line in self.p.stdout: + self.responses.put(line) + finally: + self.responses.put(None) + + def call(self, op, **kwargs): + req = { + "op": op, + **{k: b64(v) if isinstance(v, bytes) else v for k, v in kwargs.items()}, + } + self.p.stdin.write(json.dumps(req) + "\n") + self.p.stdin.flush() + try: + line = self.responses.get(timeout=5) + except Empty: + raise RuntimeError("Go peer response timed out") from None + if not line: + raise RuntimeError("Go peer exited unexpectedly") + response = json.loads(line) + if not response["ok"]: + raise Rejected(response.get("error")) + return response + + def write(self, data=b""): + return base64.b64decode(self.call("write", data=data).get("data", "")) + + def read(self, data): + return base64.b64decode(self.call("read", data=data).get("data", "")) + + def encrypt(self, data): + return base64.b64decode(self.call("encrypt", data=data).get("data", "")) + + def decrypt(self, data): + return base64.b64decode(self.call("decrypt", data=data).get("data", "")) + + def state(self): + r = self.call("state") + return base64.b64decode(r["hash"]), r["send_key_hash"], r["recv_key_hash"] + + def close(self): + try: + self.p.stdin.close() + except BrokenPipeError: + pass + if self.p.poll() is None: + try: + self.p.wait(timeout=5) + except subprocess.TimeoutExpired: + self.p.kill() + self.p.wait() + self.reader.join(timeout=5) + self.p.stdout.close() + self.p.stderr.close() + + +class PythonPeer: + def __init__(self, initiator, psk, prologue): + # Explicit validation prevents any missing-PSK downgrade in wrappers. + if len(psk) != 32: + raise Rejected("PSK must contain exactly 32 bytes") + self.n = NoiseConnection.from_name(PROFILE) + if initiator: + self.n.set_as_initiator() + else: + self.n.set_as_responder() + self.n.set_psks(psk=psk) + self.n.set_prologue(prologue) + self.n.start_handshake() + + def write(self, data=b""): + return bytes(self.n.write_message(data)) + + def read(self, data): + return bytes(self.n.read_message(data)) + + def encrypt(self, data): + return self.n.encrypt(data) + + def decrypt(self, data): + return self.n.decrypt(data) + + def state(self): + p = self.n.noise_protocol + # Test-only inspection of the candidate's internal keys. No key is logged. + return ( + self.n.get_handshake_hash(), + hashlib.sha256(p.cipher_state_encrypt.k).hexdigest(), + hashlib.sha256(p.cipher_state_decrypt.k).hexdigest(), + ) + + +def context(): + # Synthetic application binding only; not an adopted Arbiter wire schema. + return ( + b"arbiter-pairing-poc-v1\0" + secrets.token_bytes(16) + secrets.token_bytes(32) + ) + + +@contextmanager +def pair(go_initiator, *, wrong_psk=False, wrong_context=False): + psk, prologue = secrets.token_bytes(32), context() + g = GoPeer( + go_initiator, + secrets.token_bytes(32) if wrong_psk else psk, + prologue + b"changed" if wrong_context else prologue, + ) + p = PythonPeer(not go_initiator, psk, prologue) + try: + yield (g, p, psk) if go_initiator else (p, g, psk) + finally: + g.close() + + +def handshake(i, r): + # Synthetic durable public-key/identity material is carried in authenticated + # payloads. Real identity certificate profiles/PoP remain separate design work. + client = b"client-public-key:" + secrets.token_bytes(32) + server = b"server-identity:" + secrets.token_bytes(32) + first = i.write(client) + if r.read(first) != client: + raise AssertionError("client payload differs") + second = r.write(server) + if i.read(second) != server: + raise AssertionError("server payload differs") + return first, second + + +def flip(data, index=-1): + value = bytearray(data) + value[index] ^= 1 + return bytes(value) + + +REJECTION = ( + Rejected, + InvalidTag, + NoiseInvalidMessage, + NoisePSKError, + NoiseValueError, + ValueError, +) + + +class Compatibility(unittest.TestCase): + def test_correct_secret_keys_and_mutual_confirmation(self): + for role in (False, True): + with self.subTest(go_initiator=role), pair(role) as (i, r, psk): + wire = list(handshake(i, r)) + ih, isend, irecv = i.state() + rh, rsend, rrecv = r.state() + self.assertEqual(ih, rh) + self.assertEqual(isend, rrecv) + self.assertEqual(irecv, rsend) + self.assertNotEqual(isend, irecv) + for sender, recipient, label in ( + (i, r, b"client-confirm:"), + (r, i, b"server-confirm:"), + ): + proof = label + ih + ciphertext = sender.encrypt(proof) + wire.append(ciphertext) + self.assertEqual(recipient.decrypt(ciphertext), proof) + for message in wire: + self.assertNotIn(psk, message) + self.assertNotIn( + base64.urlsafe_b64encode(psk).rstrip(b"="), message + ) + + def test_wrong_secret(self): + for role in (False, True): + with ( + self.subTest(go_initiator=role), + pair(role, wrong_psk=True) as (i, r, _), + ): + with self.assertRaises(REJECTION): + r.read(i.write(b"client")) + + def test_wrong_request_context(self): + for role in (False, True): + with ( + self.subTest(go_initiator=role), + pair(role, wrong_context=True) as (i, r, _), + ): + with self.assertRaises(REJECTION): + r.read(i.write(b"client")) + + def test_first_message_tampering(self): + for role in (False, True): + for index in (0, -1): + with ( + self.subTest(go_initiator=role, index=index), + pair(role) as (i, r, _), + ): + with self.assertRaises(REJECTION): + r.read(flip(i.write(b"client-key"), index)) + + def test_second_message_tampering(self): + for role in (False, True): + for index in (0, -1): + with ( + self.subTest(go_initiator=role, index=index), + pair(role) as (i, r, _), + ): + r.read(i.write(b"client-key")) + with self.assertRaises(REJECTION): + i.read(flip(r.write(b"server-identity"), index)) + + def test_malformed_first_message(self): + for role in (False, True): + for data in (b"", b"x", b"x" * 31, b"x" * 32, b"x" * 47, b"\0" * 48): + with ( + self.subTest(go_initiator=role, length=len(data)), + pair(role) as (_, r, _), + ): + with self.assertRaises(REJECTION): + r.read(data) + + def test_malformed_second_message(self): + for role in (False, True): + for data in (b"", b"x", b"x" * 31, b"x" * 32, b"x" * 47, b"\0" * 48): + with ( + self.subTest(go_initiator=role, length=len(data)), + pair(role) as (i, r, _), + ): + r.read(i.write()) + with self.assertRaises(REJECTION): + i.read(data) + + def test_transport_tampering_both_directions(self): + for role in (False, True): + with self.subTest(go_initiator=role), pair(role) as (i, r, _): + handshake(i, r) + for sender, recipient in ((i, r), (r, i)): + with self.assertRaises(REJECTION): + recipient.decrypt( + flip(sender.encrypt(b"confirmed-key-material")) + ) + + def test_transport_replay_both_directions(self): + for role in (False, True): + with self.subTest(go_initiator=role), pair(role) as (i, r, _): + handshake(i, r) + for sender, recipient in ((i, r), (r, i)): + ciphertext = sender.encrypt(b"confirm") + self.assertEqual(recipient.decrypt(ciphertext), b"confirm") + with self.assertRaises(REJECTION): + recipient.decrypt(ciphertext) + + def test_truncated_transport_both_directions(self): + for role in (False, True): + with self.subTest(go_initiator=role), pair(role) as (i, r, _): + handshake(i, r) + for sender, recipient in ((i, r), (r, i)): + ciphertext = sender.encrypt(b"confirm") + with self.assertRaises(REJECTION): + recipient.decrypt(ciphertext[:15]) + + def test_missing_or_wrong_length_psk_fails_closed(self): + for constructor in (GoPeer, PythonPeer): + for size in (0, 8, 31, 33): + with self.subTest(peer=constructor.__name__, size=size): + with self.assertRaises(REJECTION): + constructor(True, secrets.token_bytes(size), context()) + + def test_new_sessions_are_distinct(self): + for role in (False, True): + # Same PSK and prologue still produce fresh ephemeral/session keys. + psk, prologue = secrets.token_bytes(32), context() + hashes = [] + for _ in range(2): + g = GoPeer(role, psk, prologue) + try: + p = PythonPeer(not role, psk, prologue) + i, r = (g, p) if role else (p, g) + handshake(i, r) + hashes.append(i.state()) + finally: + g.close() + self.assertNotEqual(hashes[0], hashes[1]) + + def test_replayed_first_message_cannot_confirm_new_session(self): + for role in (False, True): + psk, prologue = secrets.token_bytes(32), context() + g = GoPeer(role, psk, prologue) + second_g = None + try: + p = PythonPeer(not role, psk, prologue) + i, r = (g, p) if role else (p, g) + first, _ = handshake(i, r) + old_confirmation = i.encrypt(b"client-confirm:" + i.state()[0]) + if role: + fresh_r = PythonPeer(False, psk, prologue) + else: + second_g = GoPeer(False, psk, prologue) + fresh_r = second_g + # Noise does NOT reject a replayed first message by itself. + # Real pending-request state/attempt limits must enforce that. + fresh_r.read(first) + fresh_r.write(b"fresh-server-identity") + with self.assertRaises(REJECTION): + fresh_r.decrypt(old_confirmation) + finally: + g.close() + if second_g: + second_g.close() + + +if __name__ == "__main__": + suite = unittest.defaultTestLoader.loadTestsFromTestCase(Compatibility) + log = io.StringIO() + result = unittest.TextTestRunner(verbosity=2, stream=log).run(suite) + OUTPUT.mkdir(parents=True, exist_ok=True) + (OUTPUT / "test-output.txt").write_text(log.getvalue(), encoding="utf-8") + sys.stderr.write(log.getvalue()) + evidence = { + "profile": PROFILE.decode(), + "python": platform.python_version(), + "platform": platform.platform(), + "machine": platform.machine(), + "dependencies": { + name: importlib.metadata.version(name) + for name in ("noiseprotocol", "cryptography", "cffi", "pycparser") + }, + "test_methods": result.testsRun, + "failures": len(result.failures), + "errors": len(result.errors), + "successful": result.wasSuccessful(), + } + (OUTPUT / "test-results.json").write_text( + json.dumps(evidence, indent=2) + "\n", encoding="utf-8" + ) + sys.exit(not result.wasSuccessful()) diff --git a/experiments/pairing-noise/go.mod b/experiments/pairing-noise/go.mod new file mode 100644 index 0000000..7046a0e --- /dev/null +++ b/experiments/pairing-noise/go.mod @@ -0,0 +1,10 @@ +module arbiter-pairing-compatibility-poc + +go 1.26.0 + +require github.com/flynn/noise v1.1.0 + +require ( + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sys v0.49.0 // indirect +) diff --git a/experiments/pairing-noise/go.sum b/experiments/pairing-noise/go.sum new file mode 100644 index 0000000..3f8b851 --- /dev/null +++ b/experiments/pairing-noise/go.sum @@ -0,0 +1,19 @@ +github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= +github.com/flynn/noise v1.1.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/experiments/pairing-noise/peer.go b/experiments/pairing-noise/peer.go new file mode 100644 index 0000000..d78c1cc --- /dev/null +++ b/experiments/pairing-noise/peer.go @@ -0,0 +1,136 @@ +// Isolated interoperability probe, not a production enrollment service. +// stdin is the trusted test-control channel, NOT the simulated network. +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/flynn/noise" +) + +type command struct { + Op string `json:"op"` + Data []byte `json:"data"` + PSK []byte `json:"psk"` + Prologue []byte `json:"prologue"` + Initiator bool `json:"initiator"` +} + +type result struct { + OK bool `json:"ok"` + Data []byte `json:"data,omitempty"` + Hash []byte `json:"hash,omitempty"` + SendHash string `json:"send_key_hash,omitempty"` + RecvHash string `json:"recv_key_hash,omitempty"` + Error string `json:"error,omitempty"` +} + +type peer struct { + hs *noise.HandshakeState + send *noise.CipherState + recv *noise.CipherState + initiator bool +} + +func fingerprint(k [32]byte) string { + h := sha256.Sum256(k[:]) + return hex.EncodeToString(h[:]) +} + +func (p *peer) split(c1, c2 *noise.CipherState) { + if c1 == nil { + return + } + // Noise Split returns initiator->responder, responder->initiator. + if p.initiator { + p.send, p.recv = c1, c2 + } else { + p.send, p.recv = c2, c1 + } +} + +func (p *peer) handle(c command) (r result, err error) { + r.OK = true + if c.Op == "init" { + // Missing PSK must fail closed. flynn/noise otherwise selects plain NN. + if len(c.PSK) != 32 { + return r, errors.New("PSK must contain exactly 32 bytes") + } + p.initiator = c.Initiator + p.send, p.recv = nil, nil + p.hs, err = noise.NewHandshakeState(noise.Config{ + CipherSuite: noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256), + Pattern: noise.HandshakeNN, Initiator: c.Initiator, + PresharedKey: c.PSK, PresharedKeyPlacement: 0, Prologue: c.Prologue, + }) + return r, err + } + if p.hs == nil { + return r, errors.New("not initialized") + } + var c1, c2 *noise.CipherState + switch c.Op { + case "write": + r.Data, c1, c2, err = p.hs.WriteMessage(nil, c.Data) + if err == nil { + p.split(c1, c2) + } + case "read": + r.Data, c1, c2, err = p.hs.ReadMessage(nil, c.Data) + if err == nil { + p.split(c1, c2) + } + case "encrypt": + if p.send == nil { + return r, errors.New("handshake incomplete") + } + r.Data, err = p.send.Encrypt(nil, nil, c.Data) + case "decrypt": + if p.recv == nil { + return r, errors.New("handshake incomplete") + } + r.Data, err = p.recv.Decrypt(nil, nil, c.Data) + case "state": + if p.send == nil { + return r, errors.New("handshake incomplete") + } + r.Hash = p.hs.ChannelBinding() + // Test-only key fingerprints, never export actual keys. + r.SendHash, r.RecvHash = fingerprint(p.send.UnsafeKey()), fingerprint(p.recv.UnsafeKey()) + default: + err = errors.New("unknown operation") + } + return r, err +} + +func main() { + var p peer + s := bufio.NewScanner(os.Stdin) + s.Buffer(make([]byte, 4096), 256*1024) + out := json.NewEncoder(os.Stdout) + for s.Scan() { + var c command + var r result + err := json.Unmarshal(s.Bytes(), &c) + if err == nil { + r, err = p.handle(c) + } + if err != nil { + r = result{Error: err.Error()} + } + if err := out.Encode(r); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + if err := s.Err(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/experiments/pairing-noise/requirements.txt b/experiments/pairing-noise/requirements.txt new file mode 100644 index 0000000..8cadf05 --- /dev/null +++ b/experiments/pairing-noise/requirements.txt @@ -0,0 +1,4 @@ +noiseprotocol==0.3.1 +cryptography==48.0.1 +cffi==2.1.1 +pycparser==3.11 diff --git a/experiments/pairing-noise/run.py b/experiments/pairing-noise/run.py new file mode 100644 index 0000000..de2ab16 --- /dev/null +++ b/experiments/pairing-noise/run.py @@ -0,0 +1,91 @@ +"""Install, build and run only this experiment; leave product dependencies alone.""" + +import argparse +import json +import os +from pathlib import Path +import platform +import subprocess +import sys +import sysconfig +import venv + +ROOT = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=ROOT / "results") + parser.add_argument("--require-windows-arm64", action="store_true") + args = parser.parse_args() + output = args.output_dir.resolve() + output.mkdir(parents=True, exist_ok=True) + env = { + **os.environ, + "CGO_ENABLED": "0", + "GOTOOLCHAIN": "local", + "PAIRING_POC_OUTPUT": str(output), + } + + def run(label, command): + print(f"{label}...", flush=True) + with (output / f"{label}.txt").open("w", encoding="utf-8") as log: + result = subprocess.run( + command, + cwd=ROOT, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + timeout=900, + ) + print((output / f"{label}.txt").read_text(encoding="utf-8"), flush=True) + result.check_returncode() + + info = { + "python": sys.version, + "python_platform": sysconfig.get_platform(), + "platform": platform.platform(), + "machine": platform.machine(), + "go_version": subprocess.check_output( + ["go", "version"], env=env, text=True + ).strip(), + "go_host": subprocess.check_output( + ["go", "env", "GOHOSTOS", "GOHOSTARCH", "GOOS", "GOARCH"], + env=env, + text=True, + ).splitlines(), + } + (output / "environment.json").write_text( + json.dumps(info, indent=2) + "\n", encoding="utf-8" + ) + print(json.dumps(info, indent=2), flush=True) + if args.require_windows_arm64: + if info["python_platform"] != "win-arm64" or info["go_host"] != [ + "windows", + "arm64", + "windows", + "arm64", + ]: + raise SystemExit("Native Windows ARM64 Python and Go are required") + + venv_dir = ROOT / ".venv" + venv.EnvBuilder(with_pip=True).create(venv_dir) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + # Source builds are intentional: Windows ARM64 may lack crypto wheels. + # CI already supplies OpenSSL for this existing Arbiter dependency. + run( + "install", + [str(python), "-m", "pip", "install", "-r", str(ROOT / "requirements.txt")], + ) + run("dependencies", [str(python), "-m", "pip", "freeze"]) + build = ROOT / "build" + build.mkdir(exist_ok=True) + peer = build / ("peer.exe" if os.name == "nt" else "peer") + run("build", ["go", "build", "-buildvcs=false", "-o", str(peer), "."]) + run("binary", ["go", "version", "-m", str(peer)]) + run("vet", ["go", "vet", "."]) + run("tests", [str(python), str(ROOT / "check.py")]) + + +if __name__ == "__main__": + main()