From 10b844d6b826496a23f48e3f5ce5c387237d78ec Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Thu, 9 Jul 2026 18:37:34 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(listing):=20storage=20+=20GPU=20capabi?= =?UTF-8?q?lity=20fields=20=E2=80=94=20breaking=20v0=20wire=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CapabilityListing gains first-class storage and GPU advertisement so phones, TVs, and NAS nodes can join as storage/GPU contributors: - WorkloadOptIn gains Storage (advisory, same SPEC 5.1 discipline as Compute/Print; mirrors the coordinator's existing compute/storage/printing opt-out trichotomy). - Capacity gains StorageCommitMB: long-lived storage committed to the network (shard hosting), deliberately distinct from DiskMB job scratch. Encryption, sharding, and audit of stored data are frontend/agent concerns outside the protocol; the wire never carries stored content. - New GPUs []GPUCapability{API, Model, VRAMMB}, mirroring Printers. Advertising a GPU is the opt-in; API in {vulkan, nnapi, cuda, metal}. - JobSpec.Workload doc comment admits "storage". CanonicalBytes changes (GPUs after Printers; StorageCommitMB after DiskMB; OptIn.Storage after OptIn.Print): a breaking v0 encoding change while v0 is declared UNSTABLE. testdata/vectors.json regenerated; testdata/reproduce_spec.py updated and passing 43/43 against the new golden file. Consumers (Cloudy, SoHoLINK) must bump in lockstep; tag v0.2.0 at merge. Signed-off-by: Jodson Graves --- SPEC.md | 20 +++++++++++--- employment/employment.go | 2 +- listing/listing.go | 55 ++++++++++++++++++++++++++++++++------ listing/listing_test.go | 31 +++++++++++++++++++-- testdata/reproduce_spec.py | 8 ++++++ testdata/vectors.json | 20 +++++++++++--- vectors/vectors_test.go | 20 ++++++++++---- 7 files changed, 134 insertions(+), 22 deletions(-) diff --git a/SPEC.md b/SPEC.md index a86f7e1..39b935b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -154,11 +154,25 @@ bytes and invalid signatures. Domain tag: `sohocloud/listing/v0` Order: `NodeID` (string), `Class` (string), `Printers` (repeated: `Kind` string, -`Model` string), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64), -`Capacity.DiskMB` (int64), `Capacity.PrintQPS` (int64), `OptIn.Compute` (bool), -`OptIn.Print` (bool), `IssuedAt` (time), `Seq` (uint64). +`Model` string), `GPUs` (repeated: `API` string, `Model` string, `VRAMMB` +int64), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64), +`Capacity.DiskMB` (int64), `Capacity.StorageCommitMB` (int64), +`Capacity.PrintQPS` (int64), `OptIn.Compute` (bool), `OptIn.Print` (bool), +`OptIn.Storage` (bool), `IssuedAt` (time), `Seq` (uint64). `Class` ∈ {`micro`, `standard`, `server`}. `Kind` ∈ {`traditional`, `threed`}. +`API` ∈ {`vulkan`, `nnapi`, `cuda`, `metal`}. + +`Capacity.DiskMB` is scratch space available to a running job; +`Capacity.StorageCommitMB` is long-lived storage the node commits to hold for +the network (shard hosting). The two are deliberately distinct so a node can +offer either without the other. How stored data is encrypted, sharded, and +audited is a frontend/agent concern outside this protocol — coordination only, +the wire never carries stored content. + +Advertising a GPU is the opt-in for GPU work: a listing with no `GPUs` +receives none, and a node withdraws a GPU by omitting it from its next +listing. `Seq` is strictly monotonic per node. A coordinator MUST reject a listing whose `Seq` does not strictly exceed the last one seen for that node (replay/rollback diff --git a/employment/employment.go b/employment/employment.go index 79f1edd..b187dfa 100644 --- a/employment/employment.go +++ b/employment/employment.go @@ -18,7 +18,7 @@ import ( // advisory routing hints, not enforcement — opt-out enforcement is local to the // node (see Decline / listing.WorkloadOptIn). type JobSpec struct { - Workload string // "compute" | "print" + Workload string // "compute" | "print" | "storage" Image string Args []string PrinterKind string // set for print workloads; empty otherwise diff --git a/listing/listing.go b/listing/listing.go index 4bf58da..c2e6e07 100644 --- a/listing/listing.go +++ b/listing/listing.go @@ -1,6 +1,6 @@ -// Package listing defines a node's signed advertisement of what compute and -// physical-print work it will accept. A listing is self-issued and node-signed: -// no coordinator speaks for a node's capabilities. +// Package listing defines a node's signed advertisement of what compute, +// storage, and physical-print work it will accept. A listing is self-issued +// and node-signed: no coordinator speaks for a node's capabilities. package listing import ( @@ -37,12 +37,41 @@ type PrinterCapability struct { Model string } -// Capacity is a point-in-time resource snapshot the node advertises. +// GPUAPI identifies the compute API through which a GPU is offered. The +// protocol enumerates APIs so a coordinator can avoid offering GPU work a +// node cannot run; it does NOT define scheduling policy (SPEC §5.2). +type GPUAPI string + +const ( + GPUVulkan GPUAPI = "vulkan" // cross-platform, incl. Android / Android TV + GPUNNAPI GPUAPI = "nnapi" // Android Neural Networks API + GPUCUDA GPUAPI = "cuda" // NVIDIA desktop/server + GPUMetal GPUAPI = "metal" // Apple silicon +) + +// GPUCapability describes one GPU the node offers. Advertising a GPU is the +// opt-in: a listing with no GPUs receives no GPU work, and a node withdraws +// a GPU by omitting it from its next listing. Like everything in a listing +// this is advisory — local enforcement still governs (SPEC §5.1). +type GPUCapability struct { + API GPUAPI + Model string + VRAMMB int +} + +// Capacity is a point-in-time resource snapshot the node advertises. DiskMB +// is scratch space available to a running job; StorageCommitMB is long-lived +// storage the node commits to hold for the network (shard hosting). The two +// are deliberately distinct so a node can offer either without the other. +// How stored data is encrypted, sharded, and audited is a frontend/agent +// concern outside this protocol: coordination only — the wire never carries +// stored content. type Capacity struct { - VCPUs int - MemMB int - DiskMB int - PrintQPS int // print jobs the node will queue; 0 if none + VCPUs int + MemMB int + DiskMB int + StorageCommitMB int + PrintQPS int // print jobs the node will queue; 0 if none } // WorkloadOptIn is ADVISORY. The coordinator is NOT a security boundary for @@ -53,6 +82,7 @@ type Capacity struct { type WorkloadOptIn struct { Compute bool Print bool + Storage bool } // CapabilityListing is a node's signed capability advertisement. @@ -60,6 +90,7 @@ type CapabilityListing struct { NodeID identity.NodeID Class ComputeClass Printers []PrinterCapability + GPUs []GPUCapability Capacity Capacity OptIn WorkloadOptIn IssuedAt time.Time @@ -80,12 +111,20 @@ func (l CapabilityListing) CanonicalBytes() []byte { b.String(string(p.Kind)) b.String(p.Model) } + b.Count(len(l.GPUs)) + for _, g := range l.GPUs { + b.String(string(g.API)) + b.String(g.Model) + b.Int64(int64(g.VRAMMB)) + } b.Int64(int64(l.Capacity.VCPUs)) b.Int64(int64(l.Capacity.MemMB)) b.Int64(int64(l.Capacity.DiskMB)) + b.Int64(int64(l.Capacity.StorageCommitMB)) b.Int64(int64(l.Capacity.PrintQPS)) b.Bool(l.OptIn.Compute) b.Bool(l.OptIn.Print) + b.Bool(l.OptIn.Storage) b.Time(l.IssuedAt) b.Uint64(l.Seq) return b.Sum() diff --git a/listing/listing_test.go b/listing/listing_test.go index 535f522..97cfcfb 100644 --- a/listing/listing_test.go +++ b/listing/listing_test.go @@ -15,8 +15,9 @@ func TestListingRoundTrip(t *testing.T) { NodeID: "node-1", Class: ClassStandard, Printers: []PrinterCapability{{Kind: Printer3D, Model: "Prusa MK4"}}, - Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000}, - OptIn: WorkloadOptIn{Compute: true, Print: true}, + GPUs: []GPUCapability{{API: GPUVulkan, Model: "Adreno 750", VRAMMB: 8192}}, + Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000, StorageCommitMB: 500000}, + OptIn: WorkloadOptIn{Compute: true, Print: true, Storage: true}, IssuedAt: time.Unix(1_700_000_000, 0), Seq: 7, } @@ -26,6 +27,32 @@ func TestListingRoundTrip(t *testing.T) { } } +func TestGPUTamperDetected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := CapabilityListing{ + NodeID: "node-1", + Class: ClassMicro, + GPUs: []GPUCapability{{API: GPUNNAPI, Model: "Tensor G4", VRAMMB: 4096}}, + IssuedAt: time.Unix(1, 0), + Seq: 1, + } + l.Sign(priv) + l.GPUs[0].VRAMMB = 24576 // inflate the advertised GPU after signing + if l.Verify(pub) { + t.Fatal("tampered GPU capability verified") + } +} + +func TestStorageCommitTamperDetected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)} + l.Sign(priv) + l.Capacity.StorageCommitMB = 1_000_000 // claim storage after signing + if l.Verify(pub) { + t.Fatal("tampered storage commitment verified") + } +} + func TestListingTamperDetected(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(nil) l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)} diff --git a/testdata/reproduce_spec.py b/testdata/reproduce_spec.py index dc3a16c..0d366b1 100644 --- a/testdata/reproduce_spec.py +++ b/testdata/reproduce_spec.py @@ -100,13 +100,21 @@ def canon_capability_listing(f) -> bytes: for p in printers: out += enc_string(p["Kind"]) out += enc_string(p["Model"]) + gpus = f.get("GPUs", []) + out += uvarint(len(gpus)) # repeated: count then elements inline + for g in gpus: + out += enc_string(g["API"]) + out += enc_string(g["Model"]) + out += enc_int64(g["VRAMMB"]) cap = f["Capacity"] out += enc_int64(cap["VCPUs"]) out += enc_int64(cap["MemMB"]) out += enc_int64(cap["DiskMB"]) + out += enc_int64(cap["StorageCommitMB"]) out += enc_int64(cap["PrintQPS"]) out += enc_bool(f["OptIn"]["Compute"]) out += enc_bool(f["OptIn"]["Print"]) + out += enc_bool(f["OptIn"]["Storage"]) out += enc_time(f["_IssuedAtNanos"]) out += enc_uint64(f["Seq"]) return bytes(out) diff --git a/testdata/vectors.json b/testdata/vectors.json index 961e142..ec365b1 100644 --- a/testdata/vectors.json +++ b/testdata/vectors.json @@ -149,14 +149,28 @@ "DiskMB": 2000000, "MemMB": 65536, "PrintQPS": 3, + "StorageCommitMB": 500000, "VCPUs": 16 }, "Class": "server", + "GPUs": [ + { + "API": "cuda", + "Model": "RTX 4090", + "VRAMMB": 24576 + }, + { + "API": "vulkan", + "Model": "Adreno 750", + "VRAMMB": 8192 + } + ], "IssuedAt": "unixnano 1700000000000000123", "NodeID": "node-alpha", "OptIn": { "Compute": true, - "Print": false + "Print": false, + "Storage": true }, "Printers": [ { @@ -170,8 +184,8 @@ ], "Seq": 42 }, - "canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b340000000000000010000000000001000000000000001e84800000000000000003010017979cfe362a007b000000000000002a", - "signature_hex": "880fb9b2cb3ecc07ad4eaebf931e2ccc6583d06c8be1b8eb10b823feafb24533afbc68d8778f9e434bc4b5e11291a08673cf61efddeedb57ef3c67d1e8409c03" + "canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b3402046375646108525458203430393000000000000060000676756c6b616e0a416472656e6f2037353000000000000020000000000000000010000000000001000000000000001e8480000000000007a120000000000000000301000117979cfe362a007b000000000000002a", + "signature_hex": "996279bd351b85d29164a572fdb9b7e2a43c740c4f1fcfe3588de98da8e751ca66c1ffc8d71500930265cdc16fc2c1aecd7076fcefae2a9d5d5e62274c718207" }, { "name": "Heartbeat", diff --git a/vectors/vectors_test.go b/vectors/vectors_test.go index a6f817e..4c61239 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -218,7 +218,8 @@ func genMessages(t *testing.T) []messageCase { t.Helper() var out []messageCase - // 1. CapabilityListing — node-signed. Repeated Printers with >=2 elements. + // 1. CapabilityListing — node-signed. Repeated Printers and GPUs with >=2 + // elements each so the count-prefixed repeat encoding is exercised. { seed := seedFill(0x11) priv := ed25519.NewKeyFromSeed(seed) @@ -230,8 +231,12 @@ func genMessages(t *testing.T) []messageCase { {Kind: listing.PrinterTraditional, Model: "HP LaserJet"}, {Kind: listing.Printer3D, Model: "Prusa MK4"}, }, - Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, PrintQPS: 3}, - OptIn: listing.WorkloadOptIn{Compute: true, Print: false}, + GPUs: []listing.GPUCapability{ + {API: listing.GPUCUDA, Model: "RTX 4090", VRAMMB: 24576}, + {API: listing.GPUVulkan, Model: "Adreno 750", VRAMMB: 8192}, + }, + Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, StorageCommitMB: 500_000, PrintQPS: 3}, + OptIn: listing.WorkloadOptIn{Compute: true, Print: false, Storage: true}, IssuedAt: tAt(fixedNanoA), Seq: 42, } @@ -249,11 +254,16 @@ func genMessages(t *testing.T) []messageCase { {"Kind": string(l.Printers[0].Kind), "Model": l.Printers[0].Model}, {"Kind": string(l.Printers[1].Kind), "Model": l.Printers[1].Model}, }, + "GPUs": []map[string]any{ + {"API": string(l.GPUs[0].API), "Model": l.GPUs[0].Model, "VRAMMB": l.GPUs[0].VRAMMB}, + {"API": string(l.GPUs[1].API), "Model": l.GPUs[1].Model, "VRAMMB": l.GPUs[1].VRAMMB}, + }, "Capacity": map[string]int{ "VCPUs": l.Capacity.VCPUs, "MemMB": l.Capacity.MemMB, - "DiskMB": l.Capacity.DiskMB, "PrintQPS": l.Capacity.PrintQPS, + "DiskMB": l.Capacity.DiskMB, "StorageCommitMB": l.Capacity.StorageCommitMB, + "PrintQPS": l.Capacity.PrintQPS, }, - "OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print}, + "OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print, "Storage": l.OptIn.Storage}, "IssuedAt": "unixnano " + i64toa(fixedNanoA), "Seq": l.Seq, }), From bd133c00d24174df5d2c57d624011bbe95d34c9a Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Thu, 9 Jul 2026 21:49:11 -0400 Subject: [PATCH 2/3] feat: storage lease + audit family, node key lifecycle, GPU job hints Completes the roadmap additions (Development/SCP-completion-roadmap.md v0.3-v0.5) so the protocol covers the full substrate scope before its consumers calcify around gaps. Storage lease + audit (new package lease, SPEC 4.7-4.10). Storage is a LEASE, not a job: StorageLease is the coordinator-signed offer (opaque 32-byte ShardRef + quantized SizeClass only -- the wire never carries true sizes or content; encryption/padding/sharding stay frontend-side), fee terms inline, renewal = same LeaseID with strictly higher Seq. LeaseDecline/LeaseRelease are the node's signed exits (local_policy remains the opt-out path; sovereignty includes leaving). ProofChallenge is UNSIGNED and pull-fetched (commits no one); ProofResponse is the node-signed, self-contained possession fact -- the storage counterpart of JobReport -- with the digest formula standardized as SHA-256(Nonce || uint64be(Offset) || uint64be(Length) || range), byte-identical to the expectation Cloudy precomputes at seal time. (LeaseID, Nonce) is single-use at the verifier: replays are dead. Node key lifecycle (identity/keylife.go, SPEC 4.11-4.12). KeyRotation is signed by the CURRENT key (a rotation signed by the successor is self-installation and must fail -- tested); strictly monotonic Seq per node. KeyRevocation is signed by the key being killed and MUST be honored unconditionally -- a thief can produce one too, and the safe reading is always 'stop trusting it'; re-enrollment is out-of-band. Closes the 'first-write-wins forever' gap before real member hardware. GPU job hints (employment, SPEC 4.3). JobSpec gains advisory GPUAPI + GPUMinVRAMMB routing hints, same discipline as PrinterKind, so assignments can request what listings can now advertise. Coordinator surface: StorageCoordinator and KeyLifecycleCoordinator are OPTIONAL capability interfaces, not new Coordinator methods, so a coordinator adopts compute/print without implementing storage yet; consumers discover support by type assertion. Breaking v0 canonical change (Assignment gains two fields; six new domain tags). Vectors regenerated: 12 message cases; the stdlib-only Python reproducer gained the six encoders and passes 55/55 against the new golden file. Transport httpjson routes for the new families ride with the SoHoLINK-side implementation (named follow-up). Signed-off-by: Jodson Graves --- SPEC.md | 92 ++++++++++++++- coordinator/coordinator.go | 38 ++++++ employment/employment.go | 12 +- identity/keylife.go | 89 ++++++++++++++ identity/keylife_test.go | 64 ++++++++++ lease/lease.go | 216 ++++++++++++++++++++++++++++++++++ lease/lease_test.go | 119 +++++++++++++++++++ testdata/reproduce_spec.py | 90 ++++++++++++++ testdata/vectors.json | 106 ++++++++++++++++- vectors/vectors_test.go | 233 +++++++++++++++++++++++++++++++++++-- 10 files changed, 1038 insertions(+), 21 deletions(-) create mode 100644 identity/keylife.go create mode 100644 identity/keylife_test.go create mode 100644 lease/lease.go create mode 100644 lease/lease_test.go diff --git a/SPEC.md b/SPEC.md index 39b935b..e974119 100644 --- a/SPEC.md +++ b/SPEC.md @@ -127,7 +127,7 @@ no leading version byte, no total-length prefix wrapping the message, and no trailing bytes after the last field. The `Order:` list in §4 is exhaustive and authoritative — the message's **signature field(s)** are the ONLY fields excluded, and no field exists in the canonical bytes that is not named there. -For the six core messages of §4 that single excluded field is `Signature`. The +For the twelve core messages of §4 that single excluded field is `Signature`. The Layer-C operator messages of §11 carry **two** signatures (`Sig0` and `Sig1`) over the same canonical bytes; both are excluded exactly as `Signature` is here, and, like `Signature`, neither appears in an `Order:` clause. A verifier @@ -192,9 +192,14 @@ Domain tag: `sohocloud/assignment/v0` Order: `JobID` (string), `NodeID` (string), `Spec.Workload` (string), `Spec.Image` (string), `Spec.Args` (repeated string), `Spec.PrinterKind` -(string), `Fee.ContributorShareBps` (int64), `Fee.PlatformFeeBps` (int64), +(string), `Spec.GPUAPI` (string), `Spec.GPUMinVRAMMB` (int64), +`Fee.ContributorShareBps` (int64), `Fee.PlatformFeeBps` (int64), `OfferedAt` (time). +`Spec.GPUAPI` ∈ {``, `vulkan`, `nnapi`, `cuda`, `metal`} — an advisory routing +hint like `PrinterKind`; empty means no GPU is required and `GPUMinVRAMMB` +MUST then be `0`. + The fee terms are attached inline so a node sees the split before it commits. In the pull model there is no separate accept message. @@ -224,6 +229,83 @@ Order: `CoordinatorID` (string), `Terms.ContributorShareBps` (int64), `ContributorShareBps + PlatformFeeBps` SHOULD equal `10000`. +### 4.7 StorageLease — signed by the COORDINATOR +Domain tag: `sohocloud/lease/v0` + +Order: `LeaseID` (string), `NodeID` (string), `ShardRef` (bytes), +`SizeClass` (int64), `Fee.ContributorShareBps` (int64), +`Fee.PlatformFeeBps` (int64), `IssuedAt` (time), `ExpiresAt` (time), +`Seq` (uint64). + +Storage is a LEASE, not a job: an ongoing obligation to hold one sealed +shard for a bounded term. `ShardRef` is an opaque 32-byte content address +and `SizeClass` a quantized payload size — the protocol MUST NOT carry true +object sizes or stored content; encryption, padding, and sharding are +frontend concerns above this waist. Renewal is a new `StorageLease` for the +same `LeaseID` with strictly higher `Seq` (same rollback rule as §4.1). Fee +terms ride inline, as in §4.3. + +### 4.8 LeaseDecline — signed by the NODE +Domain tag: `sohocloud/lease-decline/v0` + +Order: `LeaseID` (string), `NodeID` (string), `Reason` (string), +`DeclinedAt` (time). + +`Reason` ∈ {`local_policy`, `capacity`, `unavailable`}; `local_policy` is the +opt-out path (§5.1) for storage exactly as §4.4 is for jobs. + +### 4.9 LeaseRelease — signed by the NODE +Domain tag: `sohocloud/lease-release/v0` + +Order: `LeaseID` (string), `NodeID` (string), `ReleasedAt` (time). + +A node may always stop holding (sovereignty includes leaving). The signed +release ends its metering; re-placing the shard is the frontend's concern. + +### 4.10 ProofChallenge / ProofResponse — response signed by the NODE +Domain tag (response): `sohocloud/proof/v0` + +A `ProofChallenge{LeaseID, Offset, Length, Nonce (16 bytes), IssuedAt}` is +NOT signed: nodes fetch challenges by polling over the authenticated channel +(pull model), and a challenge commits no one to anything. The signed +artifact is the response. + +Response Order: `LeaseID` (string), `NodeID` (string), `Offset` (int64), +`Length` (int64), `Nonce` (bytes), `Digest` (bytes), `RespondedAt` (time). + +`Digest` MUST be computed exactly as +`SHA-256(Nonce || uint64be(Offset) || uint64be(Length) || sealed[Offset : Offset+Length])` +over the sealed shard bytes. The response restates the challenged range and +nonce so it stands alone as a non-repudiable metering fact — the storage +counterpart of §4.5. A verifier MUST reject a response whose +`(LeaseID, Nonce)` it has already accepted: nonces are single-use, which is +what defeats replaying a recorded answer. + +### 4.11 KeyRotation — signed by the node's CURRENT key +Domain tag: `sohocloud/node-rotate/v0` + +Order: `NodeID` (string), `NewPublicKey` (bytes), `Algo` (string), +`RotatedAt` (time), `Seq` (uint64). + +Possession of the outgoing key authorizes naming its successor. A verifier +MUST validate the signature against the key it currently holds for the node +(a rotation signed by the successor is self-installation and MUST fail), +MUST enforce strictly monotonic `Seq` per node, and after acceptance MUST +verify subsequent node messages only against `NewPublicKey`. +`Algo` ∈ {`ed25519`} in v0. + +### 4.12 KeyRevocation — signed by the key being REVOKED +Domain tag: `sohocloud/node-revoke/v0` + +Order: `NodeID` (string), `RevokedPublicKey` (bytes), `RevokedAt` (time), +`Seq` (uint64). + +Kills a key with no successor. A verifier MUST honor a validly signed +revocation unconditionally — a thief holding the key can also produce one, +and the safe reading of any revocation is "stop trusting this key". +Re-enrollment after revocation is out-of-band, via the same path as first +enrollment, never a wire message a key thief could forge. + --- ## 5. Invariants @@ -357,7 +439,7 @@ JSON first. `MaxUint64`; both bools; the empty string and a multibyte UTF-8 string; and `time` at the Unix epoch and at a fixed nanosecond instant. -- **`messages`** — one entry per signed message type (all six of §4). Each entry +- **`messages`** — one entry per signed message type (every signed message of §4). Each entry records: `name`, `domain_tag`, `signer` (`node` or `coordinator`), the 32-byte ed25519 `seed_hex` and the derived `public_key_hex`, the message `fields`, the `canonical_bytes_hex` (the output of `CanonicalBytes()`, `Signature` excluded), @@ -443,7 +525,7 @@ canonical. orthogonal identity: the **operator** — a frontend (e.g. Cloudy) that terminates member and node identity inside itself and presents a single rotating credential to a coordinator. It is deliberately separate from node workload identity (§2): -the six core coordination messages (§4) are UNCHANGED and are NOT signed with an +the core coordination messages (§4) are UNCHANGED and are NOT signed with an operator credential. An operator holds **seven** Ed25519 keypairs (indices `0..6`) and signs each @@ -468,7 +550,7 @@ only. - **Migration seam.** The reference implementation routes operator signing and verification through a `Signer`/`Verifier` interface, implemented with stdlib Ed25519 today, precisely so the algorithm can be swapped by adding a `v1` - domain tag later without touching the six core messages. This is an + domain tag later without touching the core messages. This is an implementation note; the wire contract is the field orders and tags below. **Encoding of operator fields (no special casing).** Every field in the §11 diff --git a/coordinator/coordinator.go b/coordinator/coordinator.go index a11629f..015ae06 100644 --- a/coordinator/coordinator.go +++ b/coordinator/coordinator.go @@ -17,6 +17,7 @@ import ( "github.com/NTARI-RAND/sohocloud-protocol/employment" "github.com/NTARI-RAND/sohocloud-protocol/fees" "github.com/NTARI-RAND/sohocloud-protocol/identity" + "github.com/NTARI-RAND/sohocloud-protocol/lease" "github.com/NTARI-RAND/sohocloud-protocol/listing" "github.com/NTARI-RAND/sohocloud-protocol/liveness" ) @@ -48,3 +49,40 @@ type Coordinator interface { // Fees returns the coordinator's current signed fee declaration. Fees(ctx context.Context) (fees.FeeDeclaration, error) } + +// StorageCoordinator is the OPTIONAL storage-lease surface. It is a separate +// capability interface, not new methods on Coordinator, so a coordinator can +// adopt the compute/print protocol without implementing storage yet — a +// consumer discovers support by type assertion and treats its absence as +// "this coordinator does not lease storage", never as an error. The same +// pull model and SPIFFE-binding discipline apply throughout. +type StorageCoordinator interface { + // PollLeases returns the storage leases currently offered to the calling + // node, and the open proof challenges against its held leases. Identity + // binding rules are identical to PollJobs. + PollLeases(ctx context.Context, id identity.NodeID) ([]lease.StorageLease, []lease.ProofChallenge, error) + + // DeclineLease records a node's signed refusal of an offered lease. + DeclineLease(ctx context.Context, d lease.LeaseDecline) error + + // ReleaseLease records a node's signed early exit from a held lease. + ReleaseLease(ctx context.Context, r lease.LeaseRelease) error + + // SubmitProof records a node's signed proof of possession — the fact from + // which storage metering and payout derive. Implementations MUST reject a + // response whose (LeaseID, Nonce) was seen before: nonces are single-use. + SubmitProof(ctx context.Context, p lease.ProofResponse) error +} + +// KeyLifecycleCoordinator is the OPTIONAL node-key rotation surface, split +// out for the same staged-adoption reason as StorageCoordinator. +type KeyLifecycleCoordinator interface { + // RotateKey verifies k against the node's CURRENT key, enforces strictly + // monotonic Seq, and thereafter trusts only k.NewPublicKey for the node. + RotateKey(ctx context.Context, k identity.KeyRotation) error + + // RevokeKey kills a node key with no successor. Implementations MUST + // honor a validly signed revocation unconditionally; re-enrollment is + // out-of-band. + RevokeKey(ctx context.Context, k identity.KeyRevocation) error +} diff --git a/employment/employment.go b/employment/employment.go index b187dfa..6adcc04 100644 --- a/employment/employment.go +++ b/employment/employment.go @@ -18,10 +18,12 @@ import ( // advisory routing hints, not enforcement — opt-out enforcement is local to the // node (see Decline / listing.WorkloadOptIn). type JobSpec struct { - Workload string // "compute" | "print" | "storage" - Image string - Args []string - PrinterKind string // set for print workloads; empty otherwise + Workload string // "compute" | "print" | "storage" + Image string + Args []string + PrinterKind string // set for print workloads; empty otherwise + GPUAPI string // advisory hint: "vulkan" | "nnapi" | "cuda" | "metal"; empty = no GPU required + GPUMinVRAMMB int64 // advisory minimum VRAM for GPU work; 0 when GPUAPI is empty } // Assignment is a coordinator's signed offer of a specific job to a specific @@ -93,6 +95,8 @@ func (a Assignment) CanonicalBytes() []byte { b.String(arg) } b.String(a.Spec.PrinterKind) + b.String(a.Spec.GPUAPI) + b.Int64(a.Spec.GPUMinVRAMMB) b.Int64(int64(a.Fee.ContributorShareBps)) b.Int64(int64(a.Fee.PlatformFeeBps)) b.Time(a.OfferedAt) diff --git a/identity/keylife.go b/identity/keylife.go new file mode 100644 index 0000000..22c6688 --- /dev/null +++ b/identity/keylife.go @@ -0,0 +1,89 @@ +package identity + +import ( + "crypto/ed25519" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" +) + +// KeyRotation replaces a node's verification key. It is signed by the OLD +// key: possession of the outgoing key is what authorizes naming a successor, +// so enrollment can stop being forever. A verifier MUST validate the +// signature against the key it currently holds for the node, MUST enforce +// strictly monotonic Seq per node (rollback of a rotation is an old-key +// resurrection), and after acceptance MUST verify subsequent node messages +// against NewPublicKey only. +type KeyRotation struct { + NodeID NodeID + NewPublicKey []byte // 32-byte ed25519 public key + Algo string // "ed25519"; names the successor's algorithm explicitly + RotatedAt time.Time + Seq uint64 // strictly monotonic per node across rotations + Signature []byte // ed25519 by the OLD (current) key; excluded from canon +} + +// KeyRevocation declares a node key dead with no successor. It is signed by +// the revoked key itself; a thief holding the key can also produce this, and +// a verifier MUST honor it anyway — a revocation proves someone with the key +// wants it unusable, and the safe reading of that is always "stop trusting +// it". Re-enrollment after revocation is out-of-band (the same path as first +// enrollment), never a wire message a key thief could forge. +type KeyRevocation struct { + NodeID NodeID + RevokedPublicKey []byte // 32-byte ed25519 public key being killed + RevokedAt time.Time + Seq uint64 // strictly monotonic per node + Signature []byte // ed25519 by the revoked key; excluded from canon +} + +const ( + domainRotate = "sohocloud/node-rotate/v0" + domainRevoke = "sohocloud/node-revoke/v0" +) + +// CanonicalBytes returns the deterministic signing payload, Signature +// excluded. Byte format documented in SPEC.md §4.11. +func (k KeyRotation) CanonicalBytes() []byte { + b := canon.New(domainRotate) + b.String(string(k.NodeID)) + b.Bytes(k.NewPublicKey) + b.String(k.Algo) + b.Time(k.RotatedAt) + b.Uint64(k.Seq) + return b.Sum() +} + +// Sign sets Signature using the node's OLD (current) private key. +func (k *KeyRotation) Sign(priv ed25519.PrivateKey) { + k.Signature = ed25519.Sign(priv, k.CanonicalBytes()) +} + +// Verify reports whether Signature is valid under the node's CURRENT key — +// the one being rotated away from. +func (k KeyRotation) Verify(currentPub ed25519.PublicKey) bool { + return len(k.Signature) == ed25519.SignatureSize && + ed25519.Verify(currentPub, k.CanonicalBytes(), k.Signature) +} + +// CanonicalBytes returns the deterministic signing payload, Signature +// excluded. Byte format documented in SPEC.md §4.12. +func (k KeyRevocation) CanonicalBytes() []byte { + b := canon.New(domainRevoke) + b.String(string(k.NodeID)) + b.Bytes(k.RevokedPublicKey) + b.Time(k.RevokedAt) + b.Uint64(k.Seq) + return b.Sum() +} + +// Sign sets Signature using the private half of the key being revoked. +func (k *KeyRevocation) Sign(priv ed25519.PrivateKey) { + k.Signature = ed25519.Sign(priv, k.CanonicalBytes()) +} + +// Verify reports whether Signature is valid under the key being revoked. +func (k KeyRevocation) Verify(revokedPub ed25519.PublicKey) bool { + return len(k.Signature) == ed25519.SignatureSize && + ed25519.Verify(revokedPub, k.CanonicalBytes(), k.Signature) +} diff --git a/identity/keylife_test.go b/identity/keylife_test.go new file mode 100644 index 0000000..c74ef9a --- /dev/null +++ b/identity/keylife_test.go @@ -0,0 +1,64 @@ +package identity + +import ( + "crypto/ed25519" + "testing" + "time" +) + +func TestRotationRoundTrip(t *testing.T) { + oldPub, oldPriv, _ := ed25519.GenerateKey(nil) + newPub, _, _ := ed25519.GenerateKey(nil) + k := KeyRotation{ + NodeID: "node-1", + NewPublicKey: newPub, + Algo: "ed25519", + RotatedAt: time.Unix(1_700_000_000, 0), + Seq: 1, + } + k.Sign(oldPriv) + if !k.Verify(oldPub) { + t.Fatal("valid rotation rejected under the current key") + } +} + +func TestRotationSuccessorNotSelfAuthorizing(t *testing.T) { + oldPub, _, _ := ed25519.GenerateKey(nil) + newPub, newPriv, _ := ed25519.GenerateKey(nil) + k := KeyRotation{NodeID: "node-1", NewPublicKey: newPub, Algo: "ed25519", RotatedAt: time.Unix(1, 0), Seq: 1} + k.Sign(newPriv) // the NEW key trying to install itself + if k.Verify(oldPub) { + t.Fatal("rotation signed by the successor verified — key theft installs itself") + } +} + +func TestRotationTamperAndSeq(t *testing.T) { + oldPub, oldPriv, _ := ed25519.GenerateKey(nil) + newPub, _, _ := ed25519.GenerateKey(nil) + attacker, _, _ := ed25519.GenerateKey(nil) + k := KeyRotation{NodeID: "node-1", NewPublicKey: newPub, Algo: "ed25519", RotatedAt: time.Unix(1, 0), Seq: 3} + k.Sign(oldPriv) + swapped := k + swapped.NewPublicKey = attacker // redirect the succession after signing + if swapped.Verify(oldPub) { + t.Fatal("rotation with swapped successor verified") + } + rollback := k + rollback.Seq = 4 // replay an old rotation as a newer one + if rollback.Verify(oldPub) { + t.Fatal("Seq not covered by rotation signature") + } +} + +func TestRevocationRoundTrip(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + k := KeyRevocation{NodeID: "node-1", RevokedPublicKey: pub, RevokedAt: time.Unix(1, 0), Seq: 1} + k.Sign(priv) + if !k.Verify(pub) { + t.Fatal("valid revocation rejected") + } + k.NodeID = "node-2" // retarget the kill after signing + if k.Verify(pub) { + t.Fatal("tampered revocation verified") + } +} diff --git a/lease/lease.go b/lease/lease.go new file mode 100644 index 0000000..5f43ef6 --- /dev/null +++ b/lease/lease.go @@ -0,0 +1,216 @@ +// Package lease defines the storage employment family. Storage is a LEASE, +// not a job: an ongoing obligation to hold a sealed shard, renewed and +// audited over time, so the one-shot employment messages do not fit it. +// +// The protocol speaks ONLY in opaque shard references and quantized size +// classes — never true sizes, never content. How a shard is encrypted, +// padded, and audited for privacy is a frontend concern above this waist +// (Cloudy internal/storage); what crosses the wire is the coordinator's +// signed lease offer, the node's signed exits, and the node's signed proof +// of possession — the fact from which storage metering and payout derive, +// exactly as JobReport is for compute. +package lease + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/canon" + "github.com/NTARI-RAND/sohocloud-protocol/fees" + "github.com/NTARI-RAND/sohocloud-protocol/identity" +) + +// StorageLease is a coordinator's signed offer that a node hold one sealed +// shard for a bounded term, fee terms inline so the node sees the split +// before it commits (same discipline as employment.Assignment). Renewal is a +// new StorageLease for the same LeaseID with a strictly higher Seq; a +// verifier rejects rollback exactly as with listings. +type StorageLease struct { + LeaseID string + NodeID identity.NodeID + ShardRef [32]byte // opaque content address; carries no meaning off-manifest + SizeClass int64 // quantized payload bytes; the wire never sees true sizes + Fee fees.Terms + IssuedAt time.Time + ExpiresAt time.Time + Seq uint64 // strictly monotonic per LeaseID; renewal counter + Signature []byte // ed25519 by the coordinator; excluded from CanonicalBytes +} + +// DeclineReason mirrors the employment vocabulary; local_policy remains the +// opt-out path — enforcement on the node, never the wire. +type DeclineReason string + +const ( + DeclineLocalPolicy DeclineReason = "local_policy" + DeclineCapacity DeclineReason = "capacity" + DeclineUnavailable DeclineReason = "unavailable" +) + +// LeaseDecline is a node's signed refusal of an offered lease. +type LeaseDecline struct { + LeaseID string + NodeID identity.NodeID + Reason DeclineReason + DeclinedAt time.Time + Signature []byte // ed25519 by the node +} + +// LeaseRelease is a node's signed early exit from a lease it had accepted. +// Sovereignty includes leaving: a node may always stop holding, and the +// signed release marks the moment its metering stops and re-placement of the +// shard becomes the frontend's problem. +type LeaseRelease struct { + LeaseID string + NodeID identity.NodeID + ReleasedAt time.Time + Signature []byte // ed25519 by the node +} + +// ProofChallenge asks the node holding a lease to prove possession of one +// byte range of the sealed shard, salted by a single-use nonce. It is NOT +// signed: a node fetches challenges by polling the coordinator over the +// authenticated channel (pull model — same as assignments), and the +// challenge commits no one to anything. The signed artifact is the response. +type ProofChallenge struct { + LeaseID string + Offset int64 + Length int64 + Nonce [16]byte + IssuedAt time.Time +} + +// ProofResponse is the node's signed answer — self-contained (it restates +// the challenged range and nonce) so it stands alone as a non-repudiable +// metering fact. A verifier MUST reject a response whose Nonce it has seen +// before for that lease: nonces are single-use, which is what defeats +// replaying a recorded answer. +type ProofResponse struct { + LeaseID string + NodeID identity.NodeID + Offset int64 + Length int64 + Nonce [16]byte + Digest [32]byte + RespondedAt time.Time + Signature []byte // ed25519 by the node +} + +// ProofDigest is the one conformant response computation: +// SHA-256(Nonce || uint64be(Offset) || uint64be(Length) || sealed[Offset:Offset+Length]). +// Binding the parameters into the hash means an answer for one range never +// doubles as an answer for another. This is byte-identical to the member-side +// expectation Cloudy precomputes at seal time. +func ProofDigest(nonce [16]byte, offset, length int64, rangeBytes []byte) [32]byte { + h := sha256.New() + h.Write(nonce[:]) + var params [16]byte + binary.BigEndian.PutUint64(params[:8], uint64(offset)) + binary.BigEndian.PutUint64(params[8:], uint64(length)) + h.Write(params[:]) + h.Write(rangeBytes) + var out [32]byte + copy(out[:], h.Sum(nil)) + return out +} + +const ( + domainLease = "sohocloud/lease/v0" + domainLeaseDecline = "sohocloud/lease-decline/v0" + domainLeaseRelease = "sohocloud/lease-release/v0" + domainProof = "sohocloud/proof/v0" +) + +// CanonicalBytes returns the deterministic signing payload, Signature +// excluded. Byte format documented in SPEC.md §4.7. +func (l StorageLease) CanonicalBytes() []byte { + b := canon.New(domainLease) + b.String(l.LeaseID) + b.String(string(l.NodeID)) + b.Bytes(l.ShardRef[:]) + b.Int64(l.SizeClass) + b.Int64(int64(l.Fee.ContributorShareBps)) + b.Int64(int64(l.Fee.PlatformFeeBps)) + b.Time(l.IssuedAt) + b.Time(l.ExpiresAt) + b.Uint64(l.Seq) + return b.Sum() +} + +// Sign sets Signature using the coordinator's private key. +func (l *StorageLease) Sign(priv ed25519.PrivateKey) { + l.Signature = ed25519.Sign(priv, l.CanonicalBytes()) +} + +// Verify reports whether Signature is a valid coordinator signature. +func (l StorageLease) Verify(pub ed25519.PublicKey) bool { + return len(l.Signature) == ed25519.SignatureSize && + ed25519.Verify(pub, l.CanonicalBytes(), l.Signature) +} + +// CanonicalBytes for the decline (SPEC.md §4.8). +func (d LeaseDecline) CanonicalBytes() []byte { + b := canon.New(domainLeaseDecline) + b.String(d.LeaseID) + b.String(string(d.NodeID)) + b.String(string(d.Reason)) + b.Time(d.DeclinedAt) + return b.Sum() +} + +// Sign sets Signature using the node's private key. +func (d *LeaseDecline) Sign(priv ed25519.PrivateKey) { + d.Signature = ed25519.Sign(priv, d.CanonicalBytes()) +} + +// Verify reports whether Signature is a valid node signature. +func (d LeaseDecline) Verify(pub ed25519.PublicKey) bool { + return len(d.Signature) == ed25519.SignatureSize && + ed25519.Verify(pub, d.CanonicalBytes(), d.Signature) +} + +// CanonicalBytes for the release (SPEC.md §4.9). +func (r LeaseRelease) CanonicalBytes() []byte { + b := canon.New(domainLeaseRelease) + b.String(r.LeaseID) + b.String(string(r.NodeID)) + b.Time(r.ReleasedAt) + return b.Sum() +} + +// Sign sets Signature using the node's private key. +func (r *LeaseRelease) Sign(priv ed25519.PrivateKey) { + r.Signature = ed25519.Sign(priv, r.CanonicalBytes()) +} + +// Verify reports whether Signature is a valid node signature. +func (r LeaseRelease) Verify(pub ed25519.PublicKey) bool { + return len(r.Signature) == ed25519.SignatureSize && + ed25519.Verify(pub, r.CanonicalBytes(), r.Signature) +} + +// CanonicalBytes for the proof response (SPEC.md §4.10). +func (p ProofResponse) CanonicalBytes() []byte { + b := canon.New(domainProof) + b.String(p.LeaseID) + b.String(string(p.NodeID)) + b.Int64(p.Offset) + b.Int64(p.Length) + b.Bytes(p.Nonce[:]) + b.Bytes(p.Digest[:]) + b.Time(p.RespondedAt) + return b.Sum() +} + +// Sign sets Signature using the node's private key. +func (p *ProofResponse) Sign(priv ed25519.PrivateKey) { + p.Signature = ed25519.Sign(priv, p.CanonicalBytes()) +} + +// Verify reports whether Signature is a valid node signature. +func (p ProofResponse) Verify(pub ed25519.PublicKey) bool { + return len(p.Signature) == ed25519.SignatureSize && + ed25519.Verify(pub, p.CanonicalBytes(), p.Signature) +} diff --git a/lease/lease_test.go b/lease/lease_test.go new file mode 100644 index 0000000..8fbb8f0 --- /dev/null +++ b/lease/lease_test.go @@ -0,0 +1,119 @@ +package lease + +import ( + "bytes" + "crypto/ed25519" + "testing" + "time" + + "github.com/NTARI-RAND/sohocloud-protocol/fees" +) + +func testLease() StorageLease { + return StorageLease{ + LeaseID: "lease-1", + NodeID: "node-1", + ShardRef: [32]byte{0xAA, 0xBB}, + SizeClass: 1 << 20, + Fee: fees.Terms{ContributorShareBps: 6500, PlatformFeeBps: 3500}, + IssuedAt: time.Unix(1_700_000_000, 0), + ExpiresAt: time.Unix(1_700_000_000, 0).Add(30 * 24 * time.Hour), + Seq: 1, + } +} + +func TestLeaseRoundTrip(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := testLease() + l.Sign(priv) + if !l.Verify(pub) { + t.Fatal("valid lease signature rejected") + } +} + +func TestLeaseTamperDetected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := testLease() + l.Sign(priv) + l.SizeClass = 64 << 20 // inflate the obligation after signing + if l.Verify(pub) { + t.Fatal("tampered lease verified") + } +} + +func TestLeaseRenewalSeqIsSigned(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + l := testLease() + l.Sign(priv) + l.Seq = 2 // replay an old lease as a newer renewal + if l.Verify(pub) { + t.Fatal("Seq not covered by lease signature") + } +} + +func TestDeclineAndReleaseRoundTrip(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + d := LeaseDecline{LeaseID: "lease-1", NodeID: "node-1", Reason: DeclineLocalPolicy, DeclinedAt: time.Unix(1, 0)} + d.Sign(priv) + if !d.Verify(pub) { + t.Fatal("valid decline rejected") + } + d.Reason = DeclineCapacity + if d.Verify(pub) { + t.Fatal("tampered decline verified") + } + r := LeaseRelease{LeaseID: "lease-1", NodeID: "node-1", ReleasedAt: time.Unix(2, 0)} + r.Sign(priv) + if !r.Verify(pub) { + t.Fatal("valid release rejected") + } + r.LeaseID = "lease-2" + if r.Verify(pub) { + t.Fatal("tampered release verified") + } +} + +func TestProofResponseRoundTrip(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + sealed := bytes.Repeat([]byte{0x5C}, 4096) + nonce := [16]byte{0x01, 0x02} + digest := ProofDigest(nonce, 128, 256, sealed[128:384]) + p := ProofResponse{ + LeaseID: "lease-1", NodeID: "node-1", + Offset: 128, Length: 256, Nonce: nonce, Digest: digest, + RespondedAt: time.Unix(1_700_000_000, 0), + } + p.Sign(priv) + if !p.Verify(pub) { + t.Fatal("valid proof response rejected") + } + p.Digest[0] ^= 0x01 // forge the possession claim after signing + if p.Verify(pub) { + t.Fatal("tampered proof response verified") + } +} + +func TestProofDigestBindsParameters(t *testing.T) { + sealed := bytes.Repeat([]byte{0x42}, 1024) // uniform bytes: ranges look alike + nonce := [16]byte{0x07} + a := ProofDigest(nonce, 0, 64, sealed[0:64]) + b := ProofDigest(nonce, 64, 64, sealed[64:128]) + if a == b { + t.Fatal("digests for distinct ranges collide — parameters not bound") + } + other := [16]byte{0x08} + c := ProofDigest(other, 0, 64, sealed[0:64]) + if a == c { + t.Fatal("digest ignores nonce — precomputable") + } +} + +func TestWrongKeyRejected(t *testing.T) { + _, priv, _ := ed25519.GenerateKey(nil) + otherPub, _, _ := ed25519.GenerateKey(nil) + l := testLease() + l.Sign(priv) + if l.Verify(otherPub) { + t.Fatal("lease verified under the wrong public key") + } +} diff --git a/testdata/reproduce_spec.py b/testdata/reproduce_spec.py index 0d366b1..21830ba 100644 --- a/testdata/reproduce_spec.py +++ b/testdata/reproduce_spec.py @@ -142,6 +142,8 @@ def canon_assignment(f) -> bytes: for a in args: out += enc_string(a) out += enc_string(spec["PrinterKind"]) + out += enc_string(spec["GPUAPI"]) + out += enc_int64(spec["GPUMinVRAMMB"]) fee = f["Fee"] out += enc_int64(fee["ContributorShareBps"]) out += enc_int64(fee["PlatformFeeBps"]) @@ -183,6 +185,75 @@ def canon_feedeclaration(f) -> bytes: return bytes(out) +def canon_storage_lease(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/lease/v0") + out += enc_string(f["LeaseID"]) + out += enc_string(f["NodeID"]) + out += enc_bytes(bytes.fromhex(f["ShardRef"])) + out += enc_int64(f["SizeClass"]) + fee = f["Fee"] + out += enc_int64(fee["ContributorShareBps"]) + out += enc_int64(fee["PlatformFeeBps"]) + out += enc_time(f["_IssuedAtNanos"]) + out += enc_time(f["_ExpiresAtNanos"]) + out += enc_uint64(f["Seq"]) + return bytes(out) + + +def canon_lease_decline(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/lease-decline/v0") + out += enc_string(f["LeaseID"]) + out += enc_string(f["NodeID"]) + out += enc_string(f["Reason"]) + out += enc_time(f["_DeclinedAtNanos"]) + return bytes(out) + + +def canon_lease_release(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/lease-release/v0") + out += enc_string(f["LeaseID"]) + out += enc_string(f["NodeID"]) + out += enc_time(f["_ReleasedAtNanos"]) + return bytes(out) + + +def canon_proof_response(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/proof/v0") + out += enc_string(f["LeaseID"]) + out += enc_string(f["NodeID"]) + out += enc_int64(f["Offset"]) + out += enc_int64(f["Length"]) + out += enc_bytes(bytes.fromhex(f["Nonce"])) + out += enc_bytes(bytes.fromhex(f["Digest"])) + out += enc_time(f["_RespondedAtNanos"]) + return bytes(out) + + +def canon_key_rotation(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/node-rotate/v0") + out += enc_string(f["NodeID"]) + out += enc_bytes(bytes.fromhex(f["NewPublicKey"])) + out += enc_string(f["Algo"]) + out += enc_time(f["_RotatedAtNanos"]) + out += enc_uint64(f["Seq"]) + return bytes(out) + + +def canon_key_revocation(f) -> bytes: + out = bytearray() + out += enc_domain("sohocloud/node-revoke/v0") + out += enc_string(f["NodeID"]) + out += enc_bytes(bytes.fromhex(f["RevokedPublicKey"])) + out += enc_time(f["_RevokedAtNanos"]) + out += enc_uint64(f["Seq"]) + return bytes(out) + + # -------------------------------------------------------------------------- # Layer-C OPERATOR message encoders (SPEC.md SS11). Two signatures each, # excluded from the canonical bytes. Field order strictly from SS11. @@ -350,6 +421,12 @@ def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool: "Decline": canon_decline, "JobReport": canon_jobreport, "FeeDeclaration": canon_feedeclaration, + "StorageLease": canon_storage_lease, + "LeaseDecline": canon_lease_decline, + "LeaseRelease": canon_lease_release, + "ProofResponse": canon_proof_response, + "KeyRotation": canon_key_rotation, + "KeyRevocation": canon_key_revocation, } @@ -379,6 +456,19 @@ def nanos(key): f["_FinishedAtNanos"] = nanos("FinishedAt") elif name == "FeeDeclaration": f["_EffectiveAtNanos"] = nanos("EffectiveAt") + elif name == "StorageLease": + f["_IssuedAtNanos"] = nanos("IssuedAt") + f["_ExpiresAtNanos"] = nanos("ExpiresAt") + elif name == "LeaseDecline": + f["_DeclinedAtNanos"] = nanos("DeclinedAt") + elif name == "LeaseRelease": + f["_ReleasedAtNanos"] = nanos("ReleasedAt") + elif name == "ProofResponse": + f["_RespondedAtNanos"] = nanos("RespondedAt") + elif name == "KeyRotation": + f["_RotatedAtNanos"] = nanos("RotatedAt") + elif name == "KeyRevocation": + f["_RevokedAtNanos"] = nanos("RevokedAt") return f diff --git a/testdata/vectors.json b/testdata/vectors.json index ec365b1..b98fbdc 100644 --- a/testdata/vectors.json +++ b/testdata/vectors.json @@ -222,13 +222,15 @@ "--quality", "high" ], + "GPUAPI": "cuda", + "GPUMinVRAMMB": 8192, "Image": "ghcr.io/ntari/render:1.2.3", "PrinterKind": "", "Workload": "compute" } }, - "canonical_bytes_hex": "17736f686f636c6f75642f61737369676e6d656e742f7630086a6f622d376633610a6e6f64652d616c70686107636f6d707574651a676863722e696f2f6e746172692f72656e6465723a312e322e3304082d2d6672616d657303313230092d2d7175616c69747904686967680000000000000019640000000000000dac17979d157ea0e800", - "signature_hex": "ef39c4d7a86b6369c18d1d85b2c23d60dc957f4595b4c996f3653ef2c436584739435d09b63a700730f1e4024a27802e78cb3323ade4b8cf292a8fa44e052509" + "canonical_bytes_hex": "17736f686f636c6f75642f61737369676e6d656e742f7630086a6f622d376633610a6e6f64652d616c70686107636f6d707574651a676863722e696f2f6e746172692f72656e6465723a312e322e3304082d2d6672616d657303313230092d2d7175616c6974790468696768000463756461000000000000200000000000000019640000000000000dac17979d157ea0e800", + "signature_hex": "259a333aacd0f5a14ad66be5f3b0a2defb129545e8c46c8dbcf0df4064381e5e7996fb0dae42b149b07eeaf591d8f27e1550cea4d52739320140d72d1d9c6201" }, { "name": "Decline", @@ -278,6 +280,106 @@ }, "canonical_bytes_hex": "10736f686f636c6f75642f6665652f76300e636f6f72642d736f686f6c696e6b0000000000001b580000000000000bb817979d09f832d9000000000000000005", "signature_hex": "62bcba4a0bf06c6e6d29a3c9000899b19d9957a8713e32e25a9ec4e1541fb6868220d85b366f33ac57908ab8f1e01ecc577c04666fab4228dca2da91245d2108" + }, + { + "name": "StorageLease", + "domain_tag": "sohocloud/lease/v0", + "signer": "coordinator", + "seed_hex": "6666666666666666666666666666666666666666666666666666666666666666", + "public_key_hex": "34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746", + "fields": { + "ExpiresAt": "unixnano 1700000100000000000", + "Fee": { + "ContributorShareBps": 7000, + "PlatformFeeBps": 3000 + }, + "IssuedAt": "unixnano 1700000000000000123", + "LeaseID": "lease-01ab", + "NodeID": "node-alpha", + "Seq": 3, + "ShardRef": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "SizeClass": 1048576 + }, + "canonical_bytes_hex": "12736f686f636c6f75642f6c656173652f76300a6c656173652d303161620a6e6f64652d616c70686120000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00000000001000000000000000001b580000000000000bb817979cfe362a007b17979d157ea0e8000000000000000003", + "signature_hex": "9cb772e79d64e1dd9b7605dcb4a6b2fc2df1af1a05ec90b45875915eb145e74aa7942c08a77d16bd30d6a35465f5578250b8fe025176cc397bed790107635708" + }, + { + "name": "LeaseDecline", + "domain_tag": "sohocloud/lease-decline/v0", + "signer": "node", + "seed_hex": "7777777777777777777777777777777777777777777777777777777777777777", + "public_key_hex": "c853ad0f0cd2b619aea92ceec4fd56a24d6499d584ce79257e45cfd8139b60a7", + "fields": { + "DeclinedAt": "unixnano 1699999990000000777", + "LeaseID": "lease-01ab", + "NodeID": "node-alpha", + "Reason": "local_policy" + }, + "canonical_bytes_hex": "1a736f686f636c6f75642f6c656173652d6465636c696e652f76300a6c656173652d303161620a6e6f64652d616c7068610c6c6f63616c5f706f6c69637917979cfbe21e1f09", + "signature_hex": "a50611a2971e9699ecd1a32a75089365c192e45dd2d9ebb6b8ac3066d715f1ff3d01f331945f62076d06b6a4b9c7dc5eb09a021ca51a6dae2dd45b322fdaa30c" + }, + { + "name": "LeaseRelease", + "domain_tag": "sohocloud/lease-release/v0", + "signer": "node", + "seed_hex": "8888888888888888888888888888888888888888888888888888888888888888", + "public_key_hex": "b2491d9502ae28630a2bacb2e0c74510ffcdd328c334ff3e1393e75b2d31e7dc", + "fields": { + "LeaseID": "lease-01ab", + "NodeID": "node-alpha", + "ReleasedAt": "unixnano 1700000050500000000" + }, + "canonical_bytes_hex": "1a736f686f636c6f75642f6c656173652d72656c656173652f76300a6c656173652d303161620a6e6f64652d616c70686117979d09f832d900", + "signature_hex": "4da35d32a205017eea53126e195182d9b709320f0b353cbe6ccc22d484e88353ee1ccb20887fb2b72ad1a02c5035067e89f0910939d4a6f60cb0ca208a8e7908" + }, + { + "name": "ProofResponse", + "domain_tag": "sohocloud/proof/v0", + "signer": "node", + "seed_hex": "9999999999999999999999999999999999999999999999999999999999999999", + "public_key_hex": "332ebe8d27cb7323b3a401c1c13b5dd64bccc0e10ecda1c2b5d11a03779a85e5", + "fields": { + "Digest": "93e552ed0cc680538d018ec1a94cec76ad24074018200b8267b303c8e44dfb69", + "LeaseID": "lease-01ab", + "Length": 256, + "NodeID": "node-alpha", + "Nonce": "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", + "Offset": 128, + "RespondedAt": "unixnano 1700000100000000000" + }, + "canonical_bytes_hex": "12736f686f636c6f75642f70726f6f662f76300a6c656173652d303161620a6e6f64652d616c7068610000000000000080000000000000010010f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff2093e552ed0cc680538d018ec1a94cec76ad24074018200b8267b303c8e44dfb6917979d157ea0e800", + "signature_hex": "791cfc05e0bdbe6fa3bc8522c1825da29aa86b1bfd489f8737462b9ffe7a6435eb02d1918a0b10e53451427b890c70dcccb244828d7daca57f67095753c4fc09" + }, + { + "name": "KeyRotation", + "domain_tag": "sohocloud/node-rotate/v0", + "signer": "node (current key)", + "seed_hex": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "public_key_hex": "e734ea6c2b6257de72355e472aa05a4c487e6b463c029ed306df2f01b5636b58", + "fields": { + "Algo": "ed25519", + "NewPublicKey": "7d59c5623dd40a74aa4d5a32ac645d3b3f95daeae4c22be25476dd6a486f7382", + "NodeID": "node-alpha", + "RotatedAt": "unixnano 1700000050500000000", + "Seq": 2 + }, + "canonical_bytes_hex": "18736f686f636c6f75642f6e6f64652d726f746174652f76300a6e6f64652d616c706861207d59c5623dd40a74aa4d5a32ac645d3b3f95daeae4c22be25476dd6a486f7382076564323535313917979d09f832d9000000000000000002", + "signature_hex": "8c5fb59b87c2b5297629d09a49954e7e36e1568901ae28f9f28e6bca4fef6dae1431088b46ad6e83fd84f6c777000e6aa4c5bc1d59616c113f400383f3b8dc0b" + }, + { + "name": "KeyRevocation", + "domain_tag": "sohocloud/node-revoke/v0", + "signer": "node (revoked key)", + "seed_hex": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "public_key_hex": "ca57eed30e4a7274ef4c648f56f58f880b20d2ca25725d9e5c13c83c08c09aeb", + "fields": { + "NodeID": "node-alpha", + "RevokedAt": "unixnano 1699999990000000777", + "RevokedPublicKey": "ca57eed30e4a7274ef4c648f56f58f880b20d2ca25725d9e5c13c83c08c09aeb", + "Seq": 5 + }, + "canonical_bytes_hex": "18736f686f636c6f75642f6e6f64652d7265766f6b652f76300a6e6f64652d616c70686120ca57eed30e4a7274ef4c648f56f58f880b20d2ca25725d9e5c13c83c08c09aeb17979cfbe21e1f090000000000000005", + "signature_hex": "5cb22fbe2595bbf1321cfd8003e6e20e7db3efe6736b52fae70616dd8c9d0429fb239ac02d9bd3d933eea6dd49ae4d791a375c3b8397933b5dde94fa9373d409" } ], "operator_messages": [ diff --git a/vectors/vectors_test.go b/vectors/vectors_test.go index 4c61239..1623728 100644 --- a/vectors/vectors_test.go +++ b/vectors/vectors_test.go @@ -37,6 +37,7 @@ import ( "github.com/NTARI-RAND/sohocloud-protocol/employment" "github.com/NTARI-RAND/sohocloud-protocol/fees" "github.com/NTARI-RAND/sohocloud-protocol/identity" + "github.com/NTARI-RAND/sohocloud-protocol/lease" "github.com/NTARI-RAND/sohocloud-protocol/listing" "github.com/NTARI-RAND/sohocloud-protocol/liveness" "github.com/NTARI-RAND/sohocloud-protocol/operator" @@ -308,10 +309,12 @@ func genMessages(t *testing.T) []messageCase { JobID: "job-7f3a", NodeID: identity.NodeID("node-alpha"), Spec: employment.JobSpec{ - Workload: "compute", - Image: "ghcr.io/ntari/render:1.2.3", - Args: []string{"--frames", "120", "--quality", "high"}, - PrinterKind: "", + Workload: "compute", + Image: "ghcr.io/ntari/render:1.2.3", + Args: []string{"--frames", "120", "--quality", "high"}, + PrinterKind: "", + GPUAPI: "cuda", + GPUMinVRAMMB: 8192, }, Fee: fees.Terms{ContributorShareBps: 6500, PlatformFeeBps: 3500}, OfferedAt: tAt(fixedNanoC), @@ -327,10 +330,12 @@ func genMessages(t *testing.T) []messageCase { "JobID": a.JobID, "NodeID": string(a.NodeID), "Spec": map[string]any{ - "Workload": a.Spec.Workload, - "Image": a.Spec.Image, - "Args": a.Spec.Args, - "PrinterKind": a.Spec.PrinterKind, + "Workload": a.Spec.Workload, + "Image": a.Spec.Image, + "Args": a.Spec.Args, + "PrinterKind": a.Spec.PrinterKind, + "GPUAPI": a.Spec.GPUAPI, + "GPUMinVRAMMB": a.Spec.GPUMinVRAMMB, }, "Fee": map[string]int{ "ContributorShareBps": a.Fee.ContributorShareBps, @@ -438,6 +443,214 @@ func genMessages(t *testing.T) []messageCase { }) } + // 7. StorageLease — coordinator-signed. Bytes field (ShardRef) + two times. + { + seed := seedFill(0x66) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + var ref [32]byte + for i := range ref { + ref[i] = byte(i) + } + l := lease.StorageLease{ + LeaseID: "lease-01ab", + NodeID: identity.NodeID("node-alpha"), + ShardRef: ref, + SizeClass: 1 << 20, + Fee: fees.Terms{ContributorShareBps: 7000, PlatformFeeBps: 3000}, + IssuedAt: tAt(fixedNanoA), + ExpiresAt: tAt(fixedNanoC), + Seq: 3, + } + l.Sign(priv) + out = append(out, messageCase{ + Name: "StorageLease", + DomainTag: "sohocloud/lease/v0", + Signer: "coordinator", + SeedHex: hexOf(seed), + PublicKeyHex: hexOf(pub), + Fields: fieldsJSON(t, map[string]any{ + "LeaseID": l.LeaseID, + "NodeID": string(l.NodeID), + "ShardRef": hexOf(ref[:]), + "SizeClass": l.SizeClass, + "Fee": map[string]int{ + "ContributorShareBps": l.Fee.ContributorShareBps, + "PlatformFeeBps": l.Fee.PlatformFeeBps, + }, + "IssuedAt": "unixnano " + i64toa(fixedNanoA), + "ExpiresAt": "unixnano " + i64toa(fixedNanoC), + "Seq": l.Seq, + }), + CanonicalBytesHex: hexOf(l.CanonicalBytes()), + SignatureHex: hexOf(l.Signature), + }) + } + + // 8. LeaseDecline — node-signed. + { + seed := seedFill(0x77) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + d := lease.LeaseDecline{ + LeaseID: "lease-01ab", + NodeID: identity.NodeID("node-alpha"), + Reason: lease.DeclineLocalPolicy, + DeclinedAt: tAt(fixedNanoD), + } + d.Sign(priv) + out = append(out, messageCase{ + Name: "LeaseDecline", + DomainTag: "sohocloud/lease-decline/v0", + Signer: "node", + SeedHex: hexOf(seed), + PublicKeyHex: hexOf(pub), + Fields: fieldsJSON(t, map[string]any{ + "LeaseID": d.LeaseID, + "NodeID": string(d.NodeID), + "Reason": string(d.Reason), + "DeclinedAt": "unixnano " + i64toa(fixedNanoD), + }), + CanonicalBytesHex: hexOf(d.CanonicalBytes()), + SignatureHex: hexOf(d.Signature), + }) + } + + // 9. LeaseRelease — node-signed. + { + seed := seedFill(0x88) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + r := lease.LeaseRelease{ + LeaseID: "lease-01ab", + NodeID: identity.NodeID("node-alpha"), + ReleasedAt: tAt(fixedNanoB), + } + r.Sign(priv) + out = append(out, messageCase{ + Name: "LeaseRelease", + DomainTag: "sohocloud/lease-release/v0", + Signer: "node", + SeedHex: hexOf(seed), + PublicKeyHex: hexOf(pub), + Fields: fieldsJSON(t, map[string]any{ + "LeaseID": r.LeaseID, + "NodeID": string(r.NodeID), + "ReleasedAt": "unixnano " + i64toa(fixedNanoB), + }), + CanonicalBytesHex: hexOf(r.CanonicalBytes()), + SignatureHex: hexOf(r.Signature), + }) + } + + // 10. ProofResponse — node-signed. Two fixed bytes fields (Nonce, Digest); + // Digest computed with the SPEC 4.10 formula over a deterministic range. + { + seed := seedFill(0x99) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + var nonce [16]byte + for i := range nonce { + nonce[i] = byte(0xF0 + i) + } + rangeBytes := make([]byte, 256) + for i := range rangeBytes { + rangeBytes[i] = byte(i) + } + digest := lease.ProofDigest(nonce, 128, 256, rangeBytes) + p := lease.ProofResponse{ + LeaseID: "lease-01ab", + NodeID: identity.NodeID("node-alpha"), + Offset: 128, + Length: 256, + Nonce: nonce, + Digest: digest, + RespondedAt: tAt(fixedNanoC), + } + p.Sign(priv) + out = append(out, messageCase{ + Name: "ProofResponse", + DomainTag: "sohocloud/proof/v0", + Signer: "node", + SeedHex: hexOf(seed), + PublicKeyHex: hexOf(pub), + Fields: fieldsJSON(t, map[string]any{ + "LeaseID": p.LeaseID, + "NodeID": string(p.NodeID), + "Offset": p.Offset, + "Length": p.Length, + "Nonce": hexOf(nonce[:]), + "Digest": hexOf(digest[:]), + "RespondedAt": "unixnano " + i64toa(fixedNanoC), + }), + CanonicalBytesHex: hexOf(p.CanonicalBytes()), + SignatureHex: hexOf(p.Signature), + }) + } + + // 11. KeyRotation — signed by the CURRENT (old) key; NewPublicKey derives + // from a distinct seed so both keys appear in the vector. + { + oldSeed := seedFill(0xAA) + oldPriv := ed25519.NewKeyFromSeed(oldSeed) + oldPub := oldPriv.Public().(ed25519.PublicKey) + newPriv := ed25519.NewKeyFromSeed(seedFill(0xBB)) + newPub := newPriv.Public().(ed25519.PublicKey) + k := identity.KeyRotation{ + NodeID: identity.NodeID("node-alpha"), + NewPublicKey: newPub, + Algo: "ed25519", + RotatedAt: tAt(fixedNanoB), + Seq: 2, + } + k.Sign(oldPriv) + out = append(out, messageCase{ + Name: "KeyRotation", + DomainTag: "sohocloud/node-rotate/v0", + Signer: "node (current key)", + SeedHex: hexOf(oldSeed), + PublicKeyHex: hexOf(oldPub), + Fields: fieldsJSON(t, map[string]any{ + "NodeID": string(k.NodeID), + "NewPublicKey": hexOf(k.NewPublicKey), + "Algo": k.Algo, + "RotatedAt": "unixnano " + i64toa(fixedNanoB), + "Seq": k.Seq, + }), + CanonicalBytesHex: hexOf(k.CanonicalBytes()), + SignatureHex: hexOf(k.Signature), + }) + } + + // 12. KeyRevocation — signed by the key being revoked. + { + seed := seedFill(0xCC) + priv := ed25519.NewKeyFromSeed(seed) + pub := priv.Public().(ed25519.PublicKey) + k := identity.KeyRevocation{ + NodeID: identity.NodeID("node-alpha"), + RevokedPublicKey: pub, + RevokedAt: tAt(fixedNanoD), + Seq: 5, + } + k.Sign(priv) + out = append(out, messageCase{ + Name: "KeyRevocation", + DomainTag: "sohocloud/node-revoke/v0", + Signer: "node (revoked key)", + SeedHex: hexOf(seed), + PublicKeyHex: hexOf(pub), + Fields: fieldsJSON(t, map[string]any{ + "NodeID": string(k.NodeID), + "RevokedPublicKey": hexOf(k.RevokedPublicKey), + "RevokedAt": "unixnano " + i64toa(fixedNanoD), + "Seq": k.Seq, + }), + CanonicalBytesHex: hexOf(k.CanonicalBytes()), + SignatureHex: hexOf(k.Signature), + }) + } + return out } @@ -676,8 +889,8 @@ func TestVectorSignaturesVerify(t *testing.T) { if err := json.Unmarshal(raw, &vf); err != nil { t.Fatalf("unmarshal vectors: %v", err) } - if len(vf.Messages) != 6 { - t.Fatalf("expected 6 message vectors, got %d", len(vf.Messages)) + if len(vf.Messages) != 12 { + t.Fatalf("expected 12 message vectors, got %d", len(vf.Messages)) } for _, m := range vf.Messages { From 94d146c782c08835519cc2fe4790fbdec4b15ca8 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Fri, 10 Jul 2026 05:57:07 -0400 Subject: [PATCH 3/3] fix(audit): key-lifecycle validation, panic-safe verify, time range, proof bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the protocol findings from the 2026-07-09 substrate audit. M3 — KeyRotation.Verify now rejects a rotation naming a malformed successor (NewPublicKey != 32 bytes, or unknown Algo), matching the operator rotation path. Broader: all message Verify methods route through the new canon.VerifySig, which guards public-key AND signature length before ed25519.Verify — a wrong-length key on the wire can no longer panic a verifier (it did before; only the operator path was guarded). M4 — KeyRevocation.Verify(trustedPub) now requires RevokedPublicKey == trustedPub and self-signature by that key, closing the victim-revocation vector (a stranger's self-signed revocation naming a victim's NodeID fails against the victim's trusted key). SPEC §4.12 'honor unconditionally' wording superseded by the mandatory trusted-key binding. L4 — canon.Buffer.Time latches canon.ErrTimeOutOfRange (via Buffer.Err()) and appends nothing instead of silently wrapping an out-of-range instant; new canon.ValidTime for producers. SPEC §3 documents the enforcement. Closes the 584-year aliasing conformance defect. L5 — new lease.ProofOverShard bounds-checks Offset/Length before slicing so a malicious challenge yields lease.ErrChallengeRange, not a node-side slice panic. ProofDigest (the raw primitive, byte-identical to the vectors) is unchanged. L10 — StorageLease.SizeClass is now a named lease.SizeClass type documenting the 'agreed quantized class, never a true size' §5 invariant (intent-marking, not value-enforcing — class values remain frontend policy). NIT — OperatorRotation.Verify checks duplicate-key then algo-mismatch in two ordered passes, so the returned error is deterministic regardless of map iteration order. NO canonical-byte change: all 55 conformance vectors still match the Python reproducer byte-for-byte; new regression tests cover each guard. Still bound for the v0.2.0 tag. Signed-off-by: Jodson Graves --- SPEC.md | 32 ++++++++++++++----- canon/canon.go | 66 ++++++++++++++++++++++++++++++++++++++-- canon/canon_test.go | 44 +++++++++++++++++++++++++++ employment/employment.go | 9 ++---- fees/fees.go | 3 +- identity/keylife.go | 35 ++++++++++++++++----- identity/keylife_test.go | 49 +++++++++++++++++++++++++++++ lease/lease.go | 47 +++++++++++++++++++++------- lease/lease_test.go | 26 ++++++++++++++++ listing/listing.go | 3 +- liveness/liveness.go | 3 +- operator/operator.go | 9 ++++++ 12 files changed, 285 insertions(+), 41 deletions(-) create mode 100644 canon/canon_test.go diff --git a/SPEC.md b/SPEC.md index e974119..47a5073 100644 --- a/SPEC.md +++ b/SPEC.md @@ -108,6 +108,11 @@ Primitives, in the exact order fields are listed for each message in §4: and is time-zone independent. An instant outside the representable range of `int64` UTC Unix nanoseconds (approximately the years 1678–2262) is out of scope for v0 and MUST be treated as an encoding error rather than wrapped or clamped. + The reference encoder enforces this: `canon.Buffer.Time` appends nothing and + latches `canon.ErrTimeOutOfRange` (retrievable via `Buffer.Err()`) instead of + emitting a wrapped value, so two instants ~584 years apart can never alias to + identical bytes. A producer MUST reject via `canon.ValidTime` before signing; + a verifier/decoder MUST reject a message whose timestamp fails it. - **repeated field** — an unsigned LEB128 varint *count* (the number of elements) encoded FIRST, then each element's fields encoded in order, fully inline, using the normal primitive rules above. A repeated element that is itself a string is @@ -290,9 +295,12 @@ Order: `NodeID` (string), `NewPublicKey` (bytes), `Algo` (string), Possession of the outgoing key authorizes naming its successor. A verifier MUST validate the signature against the key it currently holds for the node (a rotation signed by the successor is self-installation and MUST fail), -MUST enforce strictly monotonic `Seq` per node, and after acceptance MUST -verify subsequent node messages only against `NewPublicKey`. -`Algo` ∈ {`ed25519`} in v0. +MUST enforce strictly monotonic `Seq` per node, and MUST reject a rotation +whose `NewPublicKey` is not a well-formed key for `Algo` (for `ed25519`, not +exactly 32 bytes) or whose `Algo` is unknown — otherwise a verifier that +begins checking subsequent messages against a malformed `NewPublicKey` would +fault. Only after all of these pass does the verifier begin verifying +subsequent node messages against `NewPublicKey`. `Algo` ∈ {`ed25519`} in v0. ### 4.12 KeyRevocation — signed by the key being REVOKED Domain tag: `sohocloud/node-revoke/v0` @@ -300,11 +308,19 @@ Domain tag: `sohocloud/node-revoke/v0` Order: `NodeID` (string), `RevokedPublicKey` (bytes), `RevokedAt` (time), `Seq` (uint64). -Kills a key with no successor. A verifier MUST honor a validly signed -revocation unconditionally — a thief holding the key can also produce one, -and the safe reading of any revocation is "stop trusting this key". -Re-enrollment after revocation is out-of-band, via the same path as first -enrollment, never a wire message a key thief could forge. +Kills a key with no successor. A verifier MUST verify the signature against +the key it **currently trusts** for `NodeID`, and MUST require that +`RevokedPublicKey` equals that trusted key. A revocation is only ever honored +against the exact key it names and kills. This is the anti-abuse binding: a +stranger can self-sign a revocation carrying a victim's `NodeID` and the +stranger's own key in `RevokedPublicKey`, but because that key is not the one +the coordinator trusts for the victim, the equality check fails and the +revocation is rejected — a node cannot be knocked offline by anyone who does +not already hold its current key. (Earlier drafts said "honor unconditionally"; +that wording is superseded — the binding to the trusted key is mandatory.) +Given the current key, the safe reading of a revocation is "stop trusting this +key". Re-enrollment after revocation is out-of-band, via the same path as +first enrollment, never a wire message a key thief could forge. --- diff --git a/canon/canon.go b/canon/canon.go index e893665..ce3951f 100644 --- a/canon/canon.go +++ b/canon/canon.go @@ -19,13 +19,43 @@ package canon import ( + "crypto/ed25519" "encoding/binary" + "errors" "time" ) +// ErrTimeOutOfRange is latched on a Buffer when Time is given an instant +// outside the representable int64-UTC-nanoseconds range (SPEC §3). It is an +// encoding error, never a wrap or clamp. +var ErrTimeOutOfRange = errors.New("canon: time out of representable int64-nanosecond range") + +// minRepresentableUnixNano / maxRepresentableUnixNano bound the instants that +// encode without int64 nanosecond overflow (approximately 1678..2262). +var ( + minRepresentableUnixNano = time.Unix(0, minInt64) + maxRepresentableUnixNano = time.Unix(0, maxInt64) +) + +const ( + minInt64 = -1 << 63 + maxInt64 = 1<<63 - 1 +) + +// ValidTime reports whether t encodes as int64 UTC Unix nanoseconds without +// overflow. Producers MUST validate timestamps with this before signing, and +// verifiers/decoders MUST reject a message whose timestamp fails it — an +// out-of-range instant is an encoding error, not a value to be wrapped +// (SPEC §3). +func ValidTime(t time.Time) bool { + u := t.UTC() + return !u.Before(minRepresentableUnixNano) && !u.After(maxRepresentableUnixNano) +} + // Buffer accumulates canonical bytes. type Buffer struct { - b []byte + b []byte + err error } // New returns a Buffer whose first entry is a domain tag identifying the @@ -86,13 +116,45 @@ func (buf *Buffer) Uint64(v uint64) *Buffer { // monotonic-clock components are dropped so the encoding is stable across a // JSON transport round-trip and across time zones. func (buf *Buffer) Time(t time.Time) *Buffer { + if !ValidTime(t) { + // Latch an encoding error and append nothing rather than wrapping — + // a wrapped value would silently alias two instants ~584 years apart + // to identical bytes (SPEC §3). Callers surface this via Err(). + if buf.err == nil { + buf.err = ErrTimeOutOfRange + } + return buf + } return buf.Int64(t.UTC().UnixNano()) } +// Err returns the first encoding error latched during construction (currently +// only ErrTimeOutOfRange), or nil. A caller that builds canonical bytes from +// untrusted field values (a verifier or transport decoder) MUST check Err() +// and reject the message before trusting Sum(). +func (buf *Buffer) Err() error { + return buf.err +} + // Sum returns a copy of the accumulated canonical bytes. The caller signs or -// verifies over exactly these bytes. +// verifies over exactly these bytes. When Err() is non-nil the returned bytes +// are incomplete by construction (the offending field was skipped, not +// wrapped), so any signature over them will fail to verify — callers should +// check Err() rather than relying on that. func (buf *Buffer) Sum() []byte { out := make([]byte, len(buf.b)) copy(out, buf.b) return out } + +// VerifySig reports whether sig is a valid ed25519 signature by pub over msg. +// Unlike calling ed25519.Verify directly it is panic-safe: it rejects (returns +// false) when pub is not ed25519.PublicKeySize or sig is not +// ed25519.SignatureSize, instead of panicking. Every message Verify method +// routes through here so a malformed key on the wire can never crash a +// verifier. +func VerifySig(pub ed25519.PublicKey, msg, sig []byte) bool { + return len(pub) == ed25519.PublicKeySize && + len(sig) == ed25519.SignatureSize && + ed25519.Verify(pub, msg, sig) +} diff --git a/canon/canon_test.go b/canon/canon_test.go new file mode 100644 index 0000000..bf12783 --- /dev/null +++ b/canon/canon_test.go @@ -0,0 +1,44 @@ +package canon + +import ( + "crypto/ed25519" + "testing" + "time" +) + +func TestVerifySigPanicSafe(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + msg := []byte("hello") + sig := ed25519.Sign(priv, msg) + if !VerifySig(pub, msg, sig) { + t.Fatal("valid signature rejected") + } + // Wrong-length key must return false, not panic. + if VerifySig([]byte{1, 2, 3}, msg, sig) { + t.Fatal("accepted a malformed public key") + } + // Wrong-length signature must return false, not panic. + if VerifySig(pub, msg, []byte{4, 5}) { + t.Fatal("accepted a malformed signature") + } +} + +func TestValidTimeAndLatch(t *testing.T) { + if !ValidTime(time.Unix(1_700_000_000, 0)) { + t.Fatal("a normal instant reported out of range") + } + // Far outside the int64-nanosecond window (year ~5000). + far := time.Date(5000, 1, 1, 0, 0, 0, 0, time.UTC) + if ValidTime(far) { + t.Fatal("an out-of-range instant reported valid") + } + b := New("test/domain").String("x").Time(far).Uint64(7) + if b.Err() == nil { + t.Fatal("out-of-range Time did not latch an error") + } + // In-range time must not latch. + b2 := New("test/domain").Time(time.Unix(1, 0)) + if b2.Err() != nil { + t.Fatalf("in-range Time latched an error: %v", b2.Err()) + } +} diff --git a/employment/employment.go b/employment/employment.go index 6adcc04..c90e5a7 100644 --- a/employment/employment.go +++ b/employment/employment.go @@ -111,8 +111,7 @@ func (a *Assignment) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid coordinator signature over the // assignment. func (a Assignment) Verify(pub ed25519.PublicKey) bool { - return len(a.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, a.CanonicalBytes(), a.Signature) + return canon.VerifySig(pub, a.CanonicalBytes(), a.Signature) } // CanonicalBytes returns the deterministic signing payload for the decline, @@ -133,8 +132,7 @@ func (d *Decline) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature over the decline. func (d Decline) Verify(pub ed25519.PublicKey) bool { - return len(d.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, d.CanonicalBytes(), d.Signature) + return canon.VerifySig(pub, d.CanonicalBytes(), d.Signature) } // CanonicalBytes returns the deterministic signing payload for the job report, @@ -158,6 +156,5 @@ func (r *JobReport) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature over the report. func (r JobReport) Verify(pub ed25519.PublicKey) bool { - return len(r.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, r.CanonicalBytes(), r.Signature) + return canon.VerifySig(pub, r.CanonicalBytes(), r.Signature) } diff --git a/fees/fees.go b/fees/fees.go index 04cb144..f8a0bf4 100644 --- a/fees/fees.go +++ b/fees/fees.go @@ -55,6 +55,5 @@ func (f *FeeDeclaration) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid coordinator signature over the // declaration. func (f FeeDeclaration) Verify(pub ed25519.PublicKey) bool { - return len(f.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, f.CanonicalBytes(), f.Signature) + return canon.VerifySig(pub, f.CanonicalBytes(), f.Signature) } diff --git a/identity/keylife.go b/identity/keylife.go index 22c6688..7f8b6e8 100644 --- a/identity/keylife.go +++ b/identity/keylife.go @@ -1,6 +1,7 @@ package identity import ( + "bytes" "crypto/ed25519" "time" @@ -59,11 +60,17 @@ func (k *KeyRotation) Sign(priv ed25519.PrivateKey) { k.Signature = ed25519.Sign(priv, k.CanonicalBytes()) } -// Verify reports whether Signature is valid under the node's CURRENT key — -// the one being rotated away from. +// Verify reports whether Signature is valid under the node's CURRENT key (the +// one being rotated away from) AND the rotation names a well-formed successor. +// It rejects a rotation whose NewPublicKey is not a 32-byte ed25519 key or +// whose Algo is not "ed25519": otherwise a coordinator that (per SPEC §4.11) +// begins verifying subsequent messages against NewPublicKey would hand a +// malformed key to ed25519.Verify and panic. Validating the successor here is +// the same discipline the operator rotation path already applies. func (k KeyRotation) Verify(currentPub ed25519.PublicKey) bool { - return len(k.Signature) == ed25519.SignatureSize && - ed25519.Verify(currentPub, k.CanonicalBytes(), k.Signature) + return k.Algo == "ed25519" && + len(k.NewPublicKey) == ed25519.PublicKeySize && + canon.VerifySig(currentPub, k.CanonicalBytes(), k.Signature) } // CanonicalBytes returns the deterministic signing payload, Signature @@ -82,8 +89,20 @@ func (k *KeyRevocation) Sign(priv ed25519.PrivateKey) { k.Signature = ed25519.Sign(priv, k.CanonicalBytes()) } -// Verify reports whether Signature is valid under the key being revoked. -func (k KeyRevocation) Verify(revokedPub ed25519.PublicKey) bool { - return len(k.Signature) == ed25519.SignatureSize && - ed25519.Verify(revokedPub, k.CanonicalBytes(), k.Signature) +// Verify reports whether the revocation is self-signed by the key it names +// AND that key is the one the caller currently trusts for the node. Pass the +// key you currently trust for k.NodeID as trustedPub. Verify returns false +// unless RevokedPublicKey both equals trustedPub and validly signed the +// message. +// +// Binding to trustedPub closes the victim-revocation vector: an attacker can +// self-sign a revocation naming a victim's NodeID with the attacker's own +// key, but RevokedPublicKey is then the attacker's key, not the one the +// coordinator trusts for that node, so bytes.Equal fails and the revocation +// is rejected. A revocation is only ever honored against the key it actually +// kills. +func (k KeyRevocation) Verify(trustedPub ed25519.PublicKey) bool { + return len(k.RevokedPublicKey) == ed25519.PublicKeySize && + bytes.Equal(k.RevokedPublicKey, trustedPub) && + canon.VerifySig(k.RevokedPublicKey, k.CanonicalBytes(), k.Signature) } diff --git a/identity/keylife_test.go b/identity/keylife_test.go index c74ef9a..c8982dc 100644 --- a/identity/keylife_test.go +++ b/identity/keylife_test.go @@ -62,3 +62,52 @@ func TestRevocationRoundTrip(t *testing.T) { t.Fatal("tampered revocation verified") } } + +// M3: a rotation naming a malformed successor must be rejected, so a +// coordinator never hands a bad key to ed25519.Verify and panics. +func TestRotationRejectsMalformedSuccessor(t *testing.T) { + oldPub, oldPriv, _ := ed25519.GenerateKey(nil) + for _, bad := range []struct { + name string + k KeyRotation + }{ + {"empty successor", KeyRotation{NodeID: "n1", NewPublicKey: []byte{}, Algo: "ed25519", RotatedAt: time.Unix(1, 0), Seq: 1}}, + {"short successor", KeyRotation{NodeID: "n1", NewPublicKey: make([]byte, 16), Algo: "ed25519", RotatedAt: time.Unix(1, 0), Seq: 1}}, + {"unknown algo", KeyRotation{NodeID: "n1", NewPublicKey: make([]byte, ed25519.PublicKeySize), Algo: "rsa", RotatedAt: time.Unix(1, 0), Seq: 1}}, + } { + k := bad.k + k.Sign(oldPriv) // validly signed by the current key + if k.Verify(oldPub) { + t.Fatalf("%s: rotation with malformed successor verified", bad.name) + } + } +} + +// M4: an attacker self-signs a revocation naming a victim's NodeID with the +// attacker's own key. Verified against the key the coordinator TRUSTS for the +// victim, it must fail — the victim cannot be knocked offline by a stranger. +func TestRevocationVictimVectorRejected(t *testing.T) { + victimTrusted, _, _ := ed25519.GenerateKey(nil) + attackerPub, attackerPriv, _ := ed25519.GenerateKey(nil) + k := KeyRevocation{NodeID: "victim", RevokedPublicKey: attackerPub, RevokedAt: time.Unix(1, 0), Seq: 1} + k.Sign(attackerPriv) // perfectly self-signed by the attacker's own key + if k.Verify(victimTrusted) { + t.Fatal("attacker revocation naming a victim verified against the victim's trusted key") + } + // And a revocation whose named key isn't the trusted one is rejected even + // if self-signed correctly. + if k.Verify(attackerPub) != true { + t.Fatal("self-consistent revocation of the attacker's OWN key should verify against that same key") + } +} + +// M3 broad: a wrong-length public key must make Verify return false, never +// panic (the pre-fix ed25519.Verify would panic). +func TestVerifyPanicSafeOnBadKey(t *testing.T) { + _, priv, _ := ed25519.GenerateKey(nil) + k := KeyRotation{NodeID: "n1", NewPublicKey: make([]byte, ed25519.PublicKeySize), Algo: "ed25519", RotatedAt: time.Unix(1, 0), Seq: 1} + k.Sign(priv) + if k.Verify([]byte{1, 2, 3}) { + t.Fatal("verify accepted a malformed current key") + } +} diff --git a/lease/lease.go b/lease/lease.go index 5f43ef6..d7ed802 100644 --- a/lease/lease.go +++ b/lease/lease.go @@ -15,6 +15,8 @@ import ( "crypto/ed25519" "crypto/sha256" "encoding/binary" + "errors" + "fmt" "time" "github.com/NTARI-RAND/sohocloud-protocol/canon" @@ -22,6 +24,15 @@ import ( "github.com/NTARI-RAND/sohocloud-protocol/identity" ) +// SizeClass is a quantized payload size (in bytes) that a coordinator and its +// frontends agree on out of band. It is a named type, not a raw byte count, to +// signal the §5 scope invariant: the wire carries only agreed size CLASSES, +// never a true object size. The protocol deliberately does NOT enumerate the +// class values — that is frontend policy (e.g. Cloudy's 1/16/64 MiB) — so this +// is intent-documenting, not value-enforcing; a coordinator still validates +// that an advertised class is one it recognizes. +type SizeClass int64 + // StorageLease is a coordinator's signed offer that a node hold one sealed // shard for a bounded term, fee terms inline so the node sees the split // before it commits (same discipline as employment.Assignment). Renewal is a @@ -30,8 +41,8 @@ import ( type StorageLease struct { LeaseID string NodeID identity.NodeID - ShardRef [32]byte // opaque content address; carries no meaning off-manifest - SizeClass int64 // quantized payload bytes; the wire never sees true sizes + ShardRef [32]byte // opaque content address; carries no meaning off-manifest + SizeClass SizeClass // agreed quantized class; the wire never sees true sizes Fee fees.Terms IssuedAt time.Time ExpiresAt time.Time @@ -116,6 +127,24 @@ func ProofDigest(nonce [16]byte, offset, length int64, rangeBytes []byte) [32]by return out } +// ErrChallengeRange is returned by ProofOverShard when a challenge's byte +// range falls outside the sealed shard. +var ErrChallengeRange = errors.New("lease: proof challenge range outside sealed shard") + +// ProofOverShard is the panic-safe way for a node to answer a challenge: it +// bounds-checks Offset/Length against the sealed shard BEFORE slicing, then +// returns ProofDigest over the requested range. A coordinator or malicious +// challenge naming a negative or out-of-range window gets ErrChallengeRange +// instead of a slice-bounds panic. For any in-range challenge the digest is +// identical to calling ProofDigest directly — this only adds the guard. +func ProofOverShard(sealed []byte, ch ProofChallenge) ([32]byte, error) { + if ch.Offset < 0 || ch.Length < 1 || ch.Offset > int64(len(sealed))-ch.Length { + return [32]byte{}, fmt.Errorf("%w: offset %d length %d over %d bytes", + ErrChallengeRange, ch.Offset, ch.Length, len(sealed)) + } + return ProofDigest(ch.Nonce, ch.Offset, ch.Length, sealed[ch.Offset:ch.Offset+ch.Length]), nil +} + const ( domainLease = "sohocloud/lease/v0" domainLeaseDecline = "sohocloud/lease-decline/v0" @@ -130,7 +159,7 @@ func (l StorageLease) CanonicalBytes() []byte { b.String(l.LeaseID) b.String(string(l.NodeID)) b.Bytes(l.ShardRef[:]) - b.Int64(l.SizeClass) + b.Int64(int64(l.SizeClass)) b.Int64(int64(l.Fee.ContributorShareBps)) b.Int64(int64(l.Fee.PlatformFeeBps)) b.Time(l.IssuedAt) @@ -146,8 +175,7 @@ func (l *StorageLease) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid coordinator signature. func (l StorageLease) Verify(pub ed25519.PublicKey) bool { - return len(l.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, l.CanonicalBytes(), l.Signature) + return canon.VerifySig(pub, l.CanonicalBytes(), l.Signature) } // CanonicalBytes for the decline (SPEC.md §4.8). @@ -167,8 +195,7 @@ func (d *LeaseDecline) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature. func (d LeaseDecline) Verify(pub ed25519.PublicKey) bool { - return len(d.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, d.CanonicalBytes(), d.Signature) + return canon.VerifySig(pub, d.CanonicalBytes(), d.Signature) } // CanonicalBytes for the release (SPEC.md §4.9). @@ -187,8 +214,7 @@ func (r *LeaseRelease) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature. func (r LeaseRelease) Verify(pub ed25519.PublicKey) bool { - return len(r.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, r.CanonicalBytes(), r.Signature) + return canon.VerifySig(pub, r.CanonicalBytes(), r.Signature) } // CanonicalBytes for the proof response (SPEC.md §4.10). @@ -211,6 +237,5 @@ func (p *ProofResponse) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature. func (p ProofResponse) Verify(pub ed25519.PublicKey) bool { - return len(p.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, p.CanonicalBytes(), p.Signature) + return canon.VerifySig(pub, p.CanonicalBytes(), p.Signature) } diff --git a/lease/lease_test.go b/lease/lease_test.go index 8fbb8f0..25a5cb6 100644 --- a/lease/lease_test.go +++ b/lease/lease_test.go @@ -3,6 +3,7 @@ package lease import ( "bytes" "crypto/ed25519" + "errors" "testing" "time" @@ -108,6 +109,31 @@ func TestProofDigestBindsParameters(t *testing.T) { } } +// L5: ProofOverShard bounds-checks before slicing so a malicious challenge +// cannot panic a node. +func TestProofOverShardBounds(t *testing.T) { + sealed := bytes.Repeat([]byte{0x5C}, 4096) + nonce := [16]byte{0x01} + ok := ProofChallenge{LeaseID: "l1", Offset: 128, Length: 256, Nonce: nonce} + got, err := ProofOverShard(sealed, ok) + if err != nil { + t.Fatalf("in-range challenge errored: %v", err) + } + if got != ProofDigest(nonce, 128, 256, sealed[128:384]) { + t.Fatal("ProofOverShard digest differs from ProofDigest for the same range") + } + for _, bad := range []ProofChallenge{ + {Offset: -1, Length: 10}, + {Offset: 0, Length: 0}, + {Offset: 4000, Length: 200}, // runs past the end + {Offset: 1 << 62, Length: 1 << 62}, + } { + if _, err := ProofOverShard(sealed, bad); !errors.Is(err, ErrChallengeRange) { + t.Fatalf("out-of-range challenge %+v: got %v, want ErrChallengeRange", bad, err) + } + } +} + func TestWrongKeyRejected(t *testing.T) { _, priv, _ := ed25519.GenerateKey(nil) otherPub, _, _ := ed25519.GenerateKey(nil) diff --git a/listing/listing.go b/listing/listing.go index c2e6e07..24fb491 100644 --- a/listing/listing.go +++ b/listing/listing.go @@ -139,6 +139,5 @@ func (l *CapabilityListing) Sign(priv ed25519.PrivateKey) { // pub is the node's public key, resolved out-of-band by the coordinator; the // protocol does not distribute keys. func (l CapabilityListing) Verify(pub ed25519.PublicKey) bool { - return len(l.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, l.CanonicalBytes(), l.Signature) + return canon.VerifySig(pub, l.CanonicalBytes(), l.Signature) } diff --git a/liveness/liveness.go b/liveness/liveness.go index c7c9219..59d9c43 100644 --- a/liveness/liveness.go +++ b/liveness/liveness.go @@ -38,6 +38,5 @@ func (h *Heartbeat) Sign(priv ed25519.PrivateKey) { // Verify reports whether Signature is a valid node signature over the // heartbeat. pub is the node's public key, resolved out-of-band. func (h Heartbeat) Verify(pub ed25519.PublicKey) bool { - return len(h.Signature) == ed25519.SignatureSize && - ed25519.Verify(pub, h.CanonicalBytes(), h.Signature) + return canon.VerifySig(pub, h.CanonicalBytes(), h.Signature) } diff --git a/operator/operator.go b/operator/operator.go index bc5179a..5fe089e 100644 --- a/operator/operator.go +++ b/operator/operator.go @@ -277,6 +277,10 @@ func (r OperatorRotation) Verify(keymap map[int]KeyRecord) error { // The same loop enforces the no-mixed-algorithms invariant (SPEC §11.2): the // new key's Algo (== r.Algo) MUST match every OTHER registered key's algo, // so a rotation can never introduce a heterogeneous, mixed-strength set. + // Two passes so the returned error is deterministic regardless of Go's map + // iteration order: duplicate-key always takes precedence over algo-mismatch + // when both are present, rather than whichever the range happened to hit + // first. for idx, rec := range keymap { if idx == r.KeyIndex { continue @@ -284,6 +288,11 @@ func (r OperatorRotation) Verify(keymap map[int]KeyRecord) error { if bytes.Equal(rec.PublicKey, r.NewPublicKey) { return ErrDuplicateSigningKey } + } + for idx, rec := range keymap { + if idx == r.KeyIndex { + continue + } if rec.Algo != r.Algo { return ErrAlgoMismatch }