diff --git a/internal/configread/binding.go b/internal/configread/binding.go new file mode 100644 index 0000000..c86d6b4 --- /dev/null +++ b/internal/configread/binding.go @@ -0,0 +1,29 @@ +package configread + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" +) + +// ExecutionBindingSHA256 binds the exact immutable task and provider bytes +// to the SIP revision delivered to an Agent. Length prefixes prevent +// concatenation collisions; no credential content is returned or logged. +func ExecutionBindingSHA256(taskJSON, providersJSON []byte, sipRevision int64) (string, error) { + if !json.Valid(taskJSON) || !json.Valid(providersJSON) || sipRevision <= 0 { + return "", errors.New("execution snapshot has invalid JSON or SIP revision") + } + h := sha256.New() + var number [8]byte + binary.BigEndian.PutUint64(number[:], uint64(len(taskJSON))) + _, _ = h.Write(number[:]) + _, _ = h.Write(taskJSON) + binary.BigEndian.PutUint64(number[:], uint64(len(providersJSON))) + _, _ = h.Write(number[:]) + _, _ = h.Write(providersJSON) + binary.BigEndian.PutUint64(number[:], uint64(sipRevision)) + _, _ = h.Write(number[:]) + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/configread/binding_test.go b/internal/configread/binding_test.go new file mode 100644 index 0000000..b10e7be --- /dev/null +++ b/internal/configread/binding_test.go @@ -0,0 +1,51 @@ +package configread + +import ( + "os" + "strings" + "testing" +) + +func TestExecutionBindingSHA256CoversTaskProvidersAndSIPRevision(t *testing.T) { + task, err := os.ReadFile("../../contracts/local/examples/config-read-task-full.json") + if err != nil { + t.Fatal(err) + } + providers, err := os.ReadFile("../../contracts/local/examples/config-read-providers.json") + if err != nil { + t.Fatal(err) + } + original, err := ExecutionBindingSHA256(task, providers, 8) + if err != nil || len(original) != 64 { + t.Fatalf("binding hash: %q %v", original, err) + } + changedTask := append(append([]byte(nil), task...), ' ') + changedProviders := append(append([]byte(nil), providers...), ' ') + for _, tc := range []struct { + name string + task []byte + providers []byte + revision int64 + }{ + {"task", changedTask, providers, 8}, + {"providers", task, changedProviders, 8}, + {"sip-revision", task, providers, 9}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ExecutionBindingSHA256(tc.task, tc.providers, tc.revision) + if err != nil || got == original { + t.Fatalf("snapshot field not bound: %q %v", got, err) + } + }) + } + for _, tc := range []struct { + task, providers []byte + revision int64 + }{ + {nil, providers, 8}, {task, nil, 8}, {[]byte(`not-json`), providers, 8}, {task, providers, 0}, + } { + if _, err := ExecutionBindingSHA256(tc.task, tc.providers, tc.revision); err == nil || strings.Contains(err.Error(), "example-only-not-a-real-secret") { + t.Fatalf("invalid binding must be rejected without printing provider credentials: %v", err) + } + } +}