diff --git a/.github/workflows/independent-node.yml b/.github/workflows/independent-node.yml new file mode 100644 index 0000000..6afb8b1 --- /dev/null +++ b/.github/workflows/independent-node.yml @@ -0,0 +1,28 @@ +name: Independent Node Program +on: + pull_request: + paths: + - 'independent-node/**' + - '.github/workflows/independent-node.yml' + push: + branches: [main] + paths: + - 'independent-node/**' +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Run Independent Node tests + run: python -m unittest discover -s independent-node/tests -v + - name: CLI smoke test + run: | + python independent-node/node.py --help + python independent-node/node.py init + python independent-node/node.py status diff --git a/README.md b/README.md index cafca9d..55c68a8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ ENTITY is Blackmore Technology Group's open-source protocol and reference implem [**15-minute first-run audit**](https://github.com/blackmore-technology-group/ENTITY/issues/80) · [**ENTITY v3.4.3 Release**](https://github.com/blackmore-technology-group/ENTITY/releases/tag/v3.4.3) · [**External Qualification**](https://github.com/blackmore-technology-group/ENTITY/issues/55) · [**ERQ Campaign**](https://github.com/blackmore-technology-group/ENTITY/issues/78) · [**Documentation**](https://blackmore-technology-group.github.io/ENTITY-DOCS/) · [**Conformance Kit**](https://github.com/blackmore-technology-group/ENTITY-Protocol-1.0-Conformance-Kit) · [**Interoperability Challenge**](docs/INTEROPERABILITY_CHALLENGE.md) +## Independent Node Program — device-to-device testing + +Run an independently operated ENTITY test node and exchange lineage-preserving test objects with another device. + +[**Install / participate**](independent-node/README.md) · [**Test campaign**](independent-node/TEST_CAMPAIGN.md) · [**Privacy & safety**](independent-node/PRIVACY.md) + +The program distinguishes BTG-controlled tests, external reproduction, and independently operated multi-node interoperability evidence. Participation does not create automatic ownership, payment, endorsement, or economic entitlement. + ## Developer entry points You do **not** need to understand all of ENTITY before building with it. diff --git a/independent-node/PRIVACY.md b/independent-node/PRIVACY.md new file mode 100644 index 0000000..1e629a2 --- /dev/null +++ b/independent-node/PRIVACY.md @@ -0,0 +1,21 @@ +# Independent Node Program — Privacy and Safety + +The Independent Node Program is designed for voluntary interoperability testing with pseudonymous node identifiers. + +## Data minimization + +The reference harness stores test state locally by default. Receipt records contain test identifiers, timestamps, SHA-256 hashes, lineage hops, endpoint role and PASS/FAIL information. The harness does not require a participant's real name, email address, GitHub account, hardware serial number, private signing key, personal files or machine inventory. + +## Network safety + +The listener binds to `127.0.0.1` by default. Binding to `0.0.0.0` is an explicit operator choice. Use a trusted LAN, VPN, isolated test network or other controlled environment. Do not port-forward the reference listener to the public Internet. + +The reference listener limits request size and accepts only the Independent Node test endpoint. It is not a production server. + +## Public evidence + +Before posting receipts or logs, remove IP addresses, local usernames, absolute paths, access tokens, private keys, secrets and personal information. Public participation is voluntary. + +## Claim boundary + +A receipt establishes what the harness observed. It does not establish legal ownership, external-world truth, regulatory compliance, monetary value, payment, endorsement, or full protocol conformance unless separately demonstrated. diff --git a/independent-node/README.md b/independent-node/README.md new file mode 100644 index 0000000..62fa60f --- /dev/null +++ b/independent-node/README.md @@ -0,0 +1,89 @@ +# ENTITY Independent Node Program + +Run an independently operated ENTITY test node and help test device-to-device communication, lineage preservation, multi-hop transfer, tamper detection, and recovery behavior. + +> This is a **test network harness**, not a production trust network. A successful transport receipt proves that the participating test nodes exchanged the recorded bytes and hashes. It does not by itself prove external-world truth, legal ownership, economic value, or independent conformance with every ENTITY protocol requirement. + +## Fast start — Windows + +Requirements: Windows 10/11, PowerShell, Python 3.11+. + +```powershell +git clone https://github.com/blackmore-technology-group/ENTITY.git +cd ENTITY +git checkout v3.4.3 +powershell -ExecutionPolicy Bypass -File independent-node/install.ps1 +python independent-node/node.py init +python independent-node/node.py status +python independent-node/node.py serve +``` + +The listener defaults to `127.0.0.1:8343`. To accept another device on your LAN, explicitly bind to an interface you control: + +```powershell +python independent-node/node.py serve --host 0.0.0.0 --port 8343 +``` + +Only expose the test listener on a network you trust. Do not expose it directly to the public Internet. + +On a second machine: + +```powershell +python independent-node/node.py init +python independent-node/node.py send --to http://DEVICE_A_IP:8343 --file independent-node/examples/sample-object.json +``` + +The sender and receiver each record privacy-minimized JSONL receipts under `.entity-independent-node/receipts/`. + +## What is recorded + +The harness creates a random pseudonymous test-node ID and records only protocol/test metadata needed for reproducibility: node ID, timestamps, receipt IDs, SHA-256 object hashes, origin hash, prior lineage hops, sender-declared node ID, receiver node ID, and verification outcome. + +It does **not** intentionally collect usernames, passwords, private keys, machine inventory, browser history, personal documents, or unrelated files. + +## Campaign + +1. **Install / node identity** — initialize a fresh independent test node. +2. **A → B** — send an object and compare the sender/receiver receipts. +3. **A → B → C** — forward the received envelope and verify the origin hash survives every hop. +4. **Tamper test** — change the object after the envelope is created; the receiver must reject the mismatch. +5. **Restart** — stop/restart the node and verify its pseudonymous node ID and local receipt history persist. +6. **Offline handoff** — copy an envelope by removable media and use `verify` before forwarding. +7. **Cross-platform** — repeat across Windows/Linux/macOS where available. + +See [TEST_CAMPAIGN.md](TEST_CAMPAIGN.md) and [PRIVACY.md](PRIVACY.md). + +## Commands + +```text +node.py init +node.py status +node.py serve [--host HOST] [--port PORT] +node.py send --to URL --file FILE +node.py verify --envelope FILE +node.py make-envelope --file FILE --out FILE +``` + +## Evidence boundary + +This harness deliberately separates three claims: + +- **BTG-controlled test:** BTG runs both endpoints. +- **External reproduction:** another operator runs the published harness and reproduces the expected result. +- **Independent interoperability evidence:** unrelated operators control different endpoints and exchange test objects. + +Do not describe one category as another. + +## Report a result + +Open the Independent Node Program participation issue in the ENTITY repository and include: + +- operating system (no serial numbers); +- ENTITY release/tag; +- test(s) attempted; +- node IDs if you are comfortable publishing them; +- receipt hashes; +- PASS/FAIL and the first reproducible failure; +- whether both endpoints were controlled by the same operator. + +A failure is useful evidence. Please redact IP addresses, usernames, filesystem paths, tokens, keys, and personal data before posting. diff --git a/independent-node/TEST_CAMPAIGN.md b/independent-node/TEST_CAMPAIGN.md new file mode 100644 index 0000000..5af34f5 --- /dev/null +++ b/independent-node/TEST_CAMPAIGN.md @@ -0,0 +1,74 @@ +# ENTITY Independent Node Test Campaign v1 + +Target: current supported ENTITY release. Historical releases may be tested only when clearly labelled. + +## INP-01 — Installation and persistence + +Initialize a fresh node, record its pseudonymous node ID, restart the process, and confirm `status` reports the same ID. + +Expected: PASS with persistent local state. + +## INP-02 — Two-device exchange + +Operator A starts a listener. Operator B sends the sample object. + +Expected: B receives an HTTP 200 receipt; A records the same object SHA-256 and origin SHA-256; receiver lineage contains the new A/B hop as appropriate. + +Record whether A and B are independently controlled. + +## INP-03 — Three-device lineage + +A creates an envelope, B receives/verifies it, then B forwards the received envelope to C. + +Expected: the original `origin_sha256` is unchanged and each hop appends rather than rewrites prior lineage. + +## INP-04 — Tamper rejection + +Create an envelope, then alter `object` without updating `object_sha256`. + +Expected: `verify` fails and a receiver rejects the envelope. + +## INP-05 — Restart/recovery + +Exchange at least one object, restart the receiving node, and inspect status/receipts. + +Expected: node identity and locally written evidence remain available. + +## INP-06 — Offline handoff + +Use `make-envelope` to write an envelope to disk, move it to another device without network transport, and run `verify`. + +Expected: hash and lineage verification PASS. + +## INP-07 — Cross-platform + +Repeat INP-02 across different operating systems. + +Expected: byte/hash semantics remain identical. + +## Evidence classification + +Every published result must state one of: + +- `BTG_CONTROLLED` +- `EXTERNAL_REPRODUCTION` +- `INDEPENDENT_MULTI_OPERATOR` + +The classification describes operator independence, not correctness. + +## Suggested result record + +```json +{ + "campaign": "ENTITY-INP-v1", + "test": "INP-02", + "entity_release": "v3.4.3", + "classification": "INDEPENDENT_MULTI_OPERATOR", + "platform_a": "Windows 11", + "platform_b": "Linux", + "receipt_sha256": "...", + "result": "PASS" +} +``` + +Do not include secrets, IP addresses, serial numbers, personal identifiers, or private filesystem paths. diff --git a/independent-node/examples/sample-object.json b/independent-node/examples/sample-object.json new file mode 100644 index 0000000..39a437e --- /dev/null +++ b/independent-node/examples/sample-object.json @@ -0,0 +1,6 @@ +{ + "schema": "entity-independent-node/sample/v1", + "kind": "lineage-test-object", + "message": "Hello from an independently operated ENTITY test node.", + "rights_notice": "TEST_ONLY_NO_ECONOMIC_ENTITLEMENT" +} diff --git a/independent-node/install.ps1 b/independent-node/install.ps1 new file mode 100644 index 0000000..89b9b6f --- /dev/null +++ b/independent-node/install.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = "Stop" +Write-Host "ENTITY Independent Node Program v1" +$py = Get-Command python -ErrorAction Stop +& python --version +if ($LASTEXITCODE -ne 0) { throw "Python is not available." } +& python independent-node/node.py --help | Out-Null +if ($LASTEXITCODE -ne 0) { throw "Independent Node harness self-check failed." } +Write-Host "Harness ready. Initialize with:" +Write-Host " python independent-node/node.py init" +Write-Host "No service was installed and no network listener was started automatically." diff --git a/independent-node/node.py b/independent-node/node.py new file mode 100644 index 0000000..233374d --- /dev/null +++ b/independent-node/node.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""ENTITY Independent Node Program v1 — minimal test transport and lineage harness. + +Standard-library only. This is not a production network service or a replacement +for ENTITY protocol qualification. It records reproducible transport/hash lineage. +""" +from __future__ import annotations +import argparse, hashlib, json, os, sys, uuid +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.request import Request, urlopen + +SCHEMA="entity-independent-node/envelope/v1" +STATE=Path(os.environ.get("ENTITY_INP_HOME", ".entity-independent-node")) +MAX_BODY=2*1024*1024 + +def now(): return datetime.now(timezone.utc).isoformat() +def canon(v): return json.dumps(v,sort_keys=True,separators=(",",":"),ensure_ascii=False).encode() +def sha(v): return hashlib.sha256(v if isinstance(v,bytes) else canon(v)).hexdigest() +def paths(): + STATE.mkdir(parents=True,exist_ok=True); (STATE/"receipts").mkdir(exist_ok=True) + return STATE/"node.json", STATE/"receipts" +def load_node(): + p,_=paths() + if not p.exists(): raise SystemExit("Node not initialized. Run: node.py init") + return json.loads(p.read_text(encoding="utf-8")) +def init(): + p,_=paths() + if p.exists(): + n=json.loads(p.read_text(encoding="utf-8")); print(n["node_id"]); return + n={"schema":"entity-independent-node/node/v1","node_id":"ent-test-"+uuid.uuid4().hex[:16],"created_at":now()} + p.write_text(json.dumps(n,indent=2)+"\n",encoding="utf-8"); print(n["node_id"]) +def make_envelope(obj,node_id): + h=sha(obj) + return {"schema":SCHEMA,"origin_sha256":h,"object_sha256":h,"object":obj,"lineage":[],"sender_node_id":node_id,"created_at":now()} +def verify_env(e): + if e.get("schema")!=SCHEMA: return False,"wrong schema" + actual=sha(e.get("object")) + if actual!=e.get("object_sha256"): return False,"object hash mismatch" + if not e.get("origin_sha256"): return False,"missing origin hash" + lineage=e.get("lineage") + if not isinstance(lineage,list): return False,"lineage is not a list" + return True,"verified" +def receipt(event,env,receiver,result): + r={"schema":"entity-independent-node/receipt/v1","receipt_id":"inp-"+uuid.uuid4().hex, + "event":event,"timestamp":now(),"receiver_node_id":receiver, + "sender_node_id":env.get("sender_node_id"),"object_sha256":env.get("object_sha256"), + "origin_sha256":env.get("origin_sha256"),"lineage_depth":len(env.get("lineage",[])), + "result":result} + r["receipt_sha256"]=sha(r) + _,d=paths(); (d/(r["receipt_id"]+".json")).write_text(json.dumps(r,indent=2)+"\n",encoding="utf-8") + return r +def receive(env,node_id): + ok,msg=verify_env(env) + if not ok: return 400,receipt("REJECT",env,node_id,"FAIL: "+msg),None + hop={"from_node_id":env.get("sender_node_id"),"to_node_id":node_id,"received_at":now(), + "object_sha256":env["object_sha256"],"origin_sha256":env["origin_sha256"]} + out=dict(env); out["lineage"]=list(env["lineage"])+[hop]; out["sender_node_id"]=node_id + r=receipt("RECEIVE",out,node_id,"PASS") + out["last_receipt_sha256"]=r["receipt_sha256"] + return 200,r,out + +class Handler(BaseHTTPRequestHandler): + server_version="ENTITY-INP/1" + def do_POST(self): + if self.path!="/entity-test/v1/receive": self.send_error(404); return + try: + n=int(self.headers.get("Content-Length","0")) + if n<=0 or n>MAX_BODY: self.send_error(413); return + env=json.loads(self.rfile.read(n).decode("utf-8")) + status,r,out=receive(env,self.server.node_id) + body=canon({"receipt":r,"envelope":out}) + self.send_response(status); self.send_header("Content-Type","application/json") + self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body) + except Exception as ex: + body=canon({"error":type(ex).__name__}) + self.send_response(400); self.send_header("Content-Type","application/json") + self.send_header("Content-Length",str(len(body))); self.end_headers(); self.wfile.write(body) + def log_message(self,fmt,*args): sys.stderr.write("%s - %s\n"%(self.address_string(),fmt%args)) + +def serve(host,port): + node=load_node(); srv=ThreadingHTTPServer((host,port),Handler); srv.node_id=node["node_id"] + print(f"ENTITY INP node {srv.node_id} listening on http://{host}:{port}") + try: srv.serve_forever() + except KeyboardInterrupt: pass +def read_obj(path): return json.loads(Path(path).read_text(encoding="utf-8")) +def cmd_make(file,out): + e=make_envelope(read_obj(file),load_node()["node_id"]) + Path(out).write_text(json.dumps(e,indent=2)+"\n",encoding="utf-8"); print(out) +def cmd_verify(path): + ok,msg=verify_env(read_obj(path)); print(("PASS: " if ok else "FAIL: ")+msg); raise SystemExit(0 if ok else 2) +def cmd_send(target,file): + node=load_node(); raw=read_obj(file) + env=raw if isinstance(raw,dict) and raw.get("schema")==SCHEMA else make_envelope(raw,node["node_id"]) + ok,msg=verify_env(env) + if not ok: raise SystemExit("Refusing invalid envelope: "+msg) + env["sender_node_id"]=node["node_id"] + req=Request(target.rstrip("/")+"/entity-test/v1/receive",data=canon(env),headers={"Content-Type":"application/json"},method="POST") + with urlopen(req,timeout=10) as res: reply=json.loads(res.read().decode()) + r=reply["receipt"]; _,d=paths(); (d/(r["receipt_id"]+"-remote.json")).write_text(json.dumps(r,indent=2)+"\n",encoding="utf-8") + print(json.dumps(reply,indent=2)) +def status(): + n=load_node(); _,d=paths(); print(json.dumps({**n,"local_receipts":len(list(d.glob("*.json")))},indent=2)) +def main(): + p=argparse.ArgumentParser(description="ENTITY Independent Node Program v1") + s=p.add_subparsers(dest="cmd",required=True) + s.add_parser("init"); s.add_parser("status") + q=s.add_parser("serve"); q.add_argument("--host",default="127.0.0.1"); q.add_argument("--port",type=int,default=8343) + q=s.add_parser("send"); q.add_argument("--to",required=True); q.add_argument("--file",required=True) + q=s.add_parser("verify"); q.add_argument("--envelope",required=True) + q=s.add_parser("make-envelope"); q.add_argument("--file",required=True); q.add_argument("--out",required=True) + a=p.parse_args() + if a.cmd=="init": init() + elif a.cmd=="status": status() + elif a.cmd=="serve": serve(a.host,a.port) + elif a.cmd=="send": cmd_send(a.to,a.file) + elif a.cmd=="verify": cmd_verify(a.envelope) + elif a.cmd=="make-envelope": cmd_make(a.file,a.out) +if __name__=="__main__": main() diff --git a/independent-node/tests/test_node.py b/independent-node/tests/test_node.py new file mode 100644 index 0000000..d98949e --- /dev/null +++ b/independent-node/tests/test_node.py @@ -0,0 +1,23 @@ +import importlib.util, json, tempfile, unittest +from pathlib import Path + +P=Path(__file__).resolve().parents[1]/"node.py" +spec=importlib.util.spec_from_file_location("inp",P); inp=importlib.util.module_from_spec(spec); spec.loader.exec_module(inp) + +class TestINP(unittest.TestCase): + def test_hash_is_canonical(self): + self.assertEqual(inp.sha({"b":2,"a":1}),inp.sha({"a":1,"b":2})) + def test_tamper_rejected(self): + e=inp.make_envelope({"x":1},"node-a"); e["object"]["x"]=2 + ok,msg=inp.verify_env(e); self.assertFalse(ok); self.assertIn("hash",msg) + def test_receive_preserves_origin_and_appends_hop(self): + with tempfile.TemporaryDirectory() as t: + old=inp.STATE; inp.STATE=Path(t) + try: + e=inp.make_envelope({"x":1},"node-a"); origin=e["origin_sha256"] + status,r,out=inp.receive(e,"node-b") + self.assertEqual(status,200); self.assertEqual(r["result"],"PASS") + self.assertEqual(out["origin_sha256"],origin); self.assertEqual(len(out["lineage"]),1) + finally: inp.STATE=old + +if __name__=="__main__": unittest.main()