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()