Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/independent-node.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions independent-node/PRIVACY.md
Original file line number Diff line number Diff line change
@@ -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.
89 changes: 89 additions & 0 deletions independent-node/README.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions independent-node/TEST_CAMPAIGN.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions independent-node/examples/sample-object.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions independent-node/install.ps1
Original file line number Diff line number Diff line change
@@ -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."
120 changes: 120 additions & 0 deletions independent-node/node.py
Original file line number Diff line number Diff line change
@@ -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()
23 changes: 23 additions & 0 deletions independent-node/tests/test_node.py
Original file line number Diff line number Diff line change
@@ -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()
Loading