From 593f25fbc6f9d5672202636e1e3fc69355b74f74 Mon Sep 17 00:00:00 2001 From: David Ahmann Date: Tue, 3 Mar 2026 15:30:05 -0500 Subject: [PATCH] refactor bundle api, add typed errors, docs/contracts, and framework mappings --- .goreleaser.yaml | 12 + CHANGELOG.md | 22 ++ CONTRIBUTING.md | 57 +++++ README.md | 221 +++++++++++++------ SECURITY.md | 22 ++ cmd/proof/errors.go | 13 ++ cmd/proof/errors_test.go | 9 + core/bundle/bundle.go | 209 ++++++++++++++++++ core/bundle/bundle_test.go | 75 +++++++ core/errors/errors.go | 100 +++++++++ core/framework/aiuc-1.yaml | 265 +++++++++++++++++++++++ core/framework/owasp-agentic-top-10.yaml | 55 +++++ core/record/record.go | 26 +-- core/record/record_test.go | 11 + core/schema/schema.go | 86 ++++++-- core/schema/schema_test.go | 20 ++ core/signing/cosign.go | 62 ++++-- core/signing/signing.go | 57 +++-- core/signing/signing_test.go | 14 ++ docs/api-contract.md | 28 +++ docs/python-integration.md | 74 +++++++ docs/release-distribution.md | 51 +++++ errors.go | 18 ++ frameworks/aiuc-1.yaml | 265 +++++++++++++++++++++++ frameworks/owasp-agentic-top-10.yaml | 55 +++++ proof.go | 198 ++++------------- proof_test.go | 35 +++ scripts/install.sh | 153 +++++++++++++ 28 files changed, 1928 insertions(+), 285 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 core/bundle/bundle.go create mode 100644 core/bundle/bundle_test.go create mode 100644 core/errors/errors.go create mode 100644 core/framework/aiuc-1.yaml create mode 100644 core/framework/owasp-agentic-top-10.yaml create mode 100644 docs/api-contract.md create mode 100644 docs/python-integration.md create mode 100644 docs/release-distribution.md create mode 100644 errors.go create mode 100644 frameworks/aiuc-1.yaml create mode 100644 frameworks/owasp-agentic-top-10.yaml create mode 100755 scripts/install.sh diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a4ffabb..66c0c5a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -30,3 +30,15 @@ release: github: owner: Clyra-AI name: proof + +brews: + - name: proof + repository: + owner: Clyra-AI + name: homebrew-tap + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Formula + homepage: "https://github.com/Clyra-AI/proof" + description: "Deterministic proof records and offline verification CLI" + license: "Apache-2.0" + skip_upload: auto diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9126e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is inspired by Keep a Changelog and this project follows semantic versioning. + +## [Unreleased] + +### Added + +- API contract documentation (`docs/api-contract.md`) and Python integration guide (`docs/python-integration.md`). +- Structured library errors (`core/errors`) with machine-readable kind/code metadata. +- Dedicated bundle domain package (`core/bundle`) with explicit pure and file-mutating signing APIs. +- New built-in framework definition files for AIUC-1 and OWASP Agentic Top 10. +- OSS hygiene docs: `CONTRIBUTING.md`, `SECURITY.md`. + +### Changed + +- Public `proof` package now aliases bundle types from `core/bundle`. +- Added `ReadAndValidateRecord` to make validated read behavior explicit. +- `SignBundle` and `SignBundleCosign` kept as deprecated wrappers over explicit file-mutating variants. +- README examples updated to use explicit error handling and JSON-native artifact examples. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..168b84c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing + +Thanks for contributing to `Clyra-AI/proof`. + +## Ground Rules + +- Keep changes inside Proof product boundaries (record/chain/sign/canon/schema/framework/verify). +- Preserve deterministic behavior and offline-first verification. +- Keep exit code semantics stable (`0-8` are reserved). +- Add or update tests with every behavior change. + +## Development Setup + +1. Install Go version from `go.mod`. +2. Clone the repo and install hooks: + +```bash +git config core.hooksPath .githooks +``` + +## Local Validation + +Run these before opening a PR: + +```bash +make fmt +make lint +make test +make contract +``` + +For full gates (coverage + integration/e2e/acceptance/hardening/perf): + +```bash +make prepush-full +``` + +## Pull Requests + +1. Create a focused branch (`codex/*` or your own feature branch naming convention). +2. Keep commits small and reviewable. +3. Include: + - behavior summary, + - compatibility impact (API/schema/exit code), + - tests added/updated. +4. Ensure CI is green before requesting merge. + +## Compatibility Expectations + +- Schema/API breaking changes require a major version bump. +- Keep `github.com/Clyra-AI/proof` and `core/*` exports backward compatible within the current major. +- Compatibility shims (`/signing`, `/schema`, `/canon`, `/exitcode`) should continue working in v1. + +## Release Notes + +- Update [CHANGELOG.md](CHANGELOG.md) for user-facing changes. +- Call out any migration guidance for deprecated APIs. diff --git a/README.md b/README.md index 837a95e..8044c7f 100644 --- a/README.md +++ b/README.md @@ -41,27 +41,37 @@ PROOF_VERSION="$(gh release view --repo Clyra-AI/proof --json tagName -q .tagNam go install github.com/Clyra-AI/proof/cmd/proof@"${PROOF_VERSION}" proof types list # 18 built-in record types -proof frameworks list # 8 built-in starter frameworks (12 controls) +proof frameworks list # 10 built-in starter frameworks (73 controls) proof verify ./artifact # Verify any proof artifact offline ``` -### Library Usage (4 lines) +### Library Usage ```go import "github.com/Clyra-AI/proof" -record, _ := proof.NewRecord(proof.RecordOpts{ - Source: "my-mcp-server", - SourceProduct: "my-product", - Type: "tool_invocation", - Event: map[string]any{"tool": "postgres_query", "action": "SELECT"}, +record, err := proof.NewRecord(proof.RecordOpts{ + Source: "my-mcp-server", + SourceProduct: "my-product", + Type: "tool_invocation", + Event: map[string]any{"tool": "postgres_query", "action": "SELECT"}, }) +if err != nil { + return err +} chain := proof.NewChain("default") -_ = proof.AppendToChain(chain, record) - -key, _ := proof.GenerateSigningKey() -_, _ = proof.Sign(&chain.Records[0], key) +if err := proof.AppendToChain(chain, record); err != nil { + return err +} + +key, err := proof.GenerateSigningKey() +if err != nil { + return err +} +if _, err := proof.Sign(&chain.Records[0], key); err != nil { + return err +} ``` Every tool invocation now produces a signed, chainable, verifiable proof record. No configuration files, no account, no network access. @@ -69,20 +79,30 @@ Every tool invocation now produces a signed, chainable, verifiable proof record. ### Custom Record Types ```go -_ = proof.RegisterCustomTypeSchema("vendor.custom_event", "./custom.schema.json") +if err := proof.RegisterCustomTypeSchema("vendor.custom_event", "./custom.schema.json"); err != nil { + return err +} ``` Custom types validate against the base proof record schema plus your type-specific schema. They chain and sign identically to built-in types. +### Read Paths + +- `proof.ReadRecord(path)` parses JSON into a `Record` without schema validation. +- `proof.ReadAndValidateRecord(path)` parses and validates both record structure and type-specific schema. + ### Governance Events ```go -record, _ := proof.NewRecordFromEvent(proof.GovernanceEvent{ - EventID: "evt-1", - Timestamp: "2026-02-20T12:00:00Z", - EventType: "tool_gate", - Detail: map[string]any{"verdict": "allow"}, +record, err := proof.NewRecordFromEvent(proof.GovernanceEvent{ + EventID: "evt-1", + Timestamp: "2026-02-20T12:00:00Z", + EventType: "tool_gate", + Detail: map[string]any{"verdict": "allow"}, }, "axym") +if err != nil { + return err +} ``` Use `schemas/v1/governance-event-v1.schema.json` for event validation. See `docs/governance-events.md` and `docs/record-context-keys.md`. @@ -90,59 +110,75 @@ Use `schemas/v1/governance-event-v1.schema.json` for event validation. See `docs ### Bundle Signing and Verification ```go -manifest, _ := proof.SignBundle("./bundle", key) - -result, _ := proof.VerifyBundle("./bundle", proof.BundleVerifyOpts{ - VerifySignatures: true, - PublicKey: proof.PublicKey{Public: key.Public}, +signedManifest, err := proof.SignBundleManifest(inputManifest, key) // pure: no file mutation +if err != nil { + return err +} + +_, err = proof.SignBundleFile("./bundle", key) // explicit file mutation of ./bundle/manifest.json +if err != nil { + return err +} + +verified, err := proof.VerifyBundle("./bundle", proof.BundleVerifyOpts{ + VerifySignatures: true, + PublicKey: proof.PublicKey{Public: key.Public}, }) +if err != nil { + return err +} +_ = signedManifest +_ = verified ``` ## Record Format The atomic unit is the proof record — a structured, signed artifact that captures what happened, what controls were in place, and the cryptographic integrity needed to prove it wasn't tampered with. -```yaml -record_id: "prf-2026-09-15T10:30:00Z-a7f3b2c1" -record_version: "1.0" -timestamp: "2026-09-15T10:30:00Z" -source: "my-mcp-server" -source_product: "my-product" -record_type: "tool_invocation" - -event: - tool: "postgres_query" - action: "SELECT" - parameters: - query_hash: "sha256:abc123..." # digest, not the query itself - target: "payments.transactions" - -controls: - permissions_enforced: true - approved_scope: "read-only on payments.*" - within_scope: true - -relationship: - parent_ref: - kind: "trace" - id: "trace_2026_09_15_1030" - entity_refs: - - kind: "agent" - id: "agent:billing-assistant" - - kind: "tool" - id: "tool:postgres_query" - - kind: "resource" - id: "resource:payments.transactions" - policy_ref: - policy_id: "prod.tool-access" - policy_version: "v3" - policy_digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - -integrity: - record_hash: "sha256:def456..." - previous_record_hash: "sha256:ghi789..." # chain link - signing_key_id: "a1b2c3..." - signature: "base64:..." +```json +{ + "record_id": "prf-2026-09-15T10:30:00Z-a7f3b2c1", + "record_version": "1.0", + "timestamp": "2026-09-15T10:30:00Z", + "source": "my-mcp-server", + "source_product": "my-product", + "record_type": "tool_invocation", + "event": { + "tool": "postgres_query", + "action": "SELECT", + "parameters": { + "query_hash": "sha256:abc123...", + "target": "payments.transactions" + } + }, + "controls": { + "permissions_enforced": true, + "approved_scope": "read-only on payments.*", + "within_scope": true + }, + "relationship": { + "parent_ref": { + "kind": "trace", + "id": "trace_2026_09_15_1030" + }, + "entity_refs": [ + {"kind": "agent", "id": "agent:billing-assistant"}, + {"kind": "tool", "id": "tool:postgres_query"}, + {"kind": "resource", "id": "resource:payments.transactions"} + ], + "policy_ref": { + "policy_id": "prod.tool-access", + "policy_version": "v3", + "policy_digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + "integrity": { + "record_hash": "sha256:def456...", + "previous_record_hash": "sha256:ghi789...", + "signing_key_id": "a1b2c3...", + "signature": "base64:..." + } +} ``` Records are immutable, deterministic, and JSON-native — readable by any language, any tool, any text editor. @@ -225,7 +261,7 @@ controls: minimum_frequency: continuous ``` -8 built-in starter frameworks ship with v1 (12 controls total). Add custom frameworks via YAML. +10 built-in starter frameworks ship with v1 (73 controls total). Add custom frameworks via YAML. | Framework | Scope | |---|---| @@ -237,8 +273,14 @@ controls: | Colorado AI Act | State AI regulation | | ISO 42001 | AI Management System | | NIST AI 600-1 | Agent security guidance | +| AIUC-1 | AI use case controls and assurance requirements | +| OWASP Agentic Top 10 | Agentic application security risk coverage | Built-ins are starter definitions; teams can add custom frameworks via YAML files. +Control IDs/titles for AIUC-1 and OWASP Agentic Top 10 are sourced from their published framework documents. + +- AIUC-1: (and linked Security/Safety/Reliability/Accountability/Society sections) +- OWASP Agentic Top 10: ## CLI Reference @@ -308,6 +350,9 @@ Compatibility packages for migration: - `github.com/Clyra-AI/proof/schema` — `ValidateJSON()` and `ValidateJSONL()` helpers - `github.com/Clyra-AI/proof/exitcode` — Exit code constants with legacy aliases +Public API support and deprecation status are documented in [docs/api-contract.md](docs/api-contract.md). +Python-first integration patterns are documented in [docs/python-integration.md](docs/python-integration.md). + ## Design Principles 1. **The spec is the product.** JSON Schema files are the normative specification. The Go module is the reference implementation. If they disagree, the spec wins. @@ -325,10 +370,12 @@ core/ record/ Record creation, validation, hashing chain/ Hash chain append and verification signing/ Ed25519 + cosign signing + bundle/ Bundle manifests and bundle sign/verify canon/ Canonicalization (JSON, SQL, URL, text) schema/ JSON Schema validation + type registry framework/ YAML framework definition loading gait/ Gait pack/runpack compatibility verification + errors/ Structured library errors (kind/code/path/field) exitcode/ Exit code constants signing/ Compatibility package (Gait migration) canon/ Compatibility package (Gait migration) @@ -336,7 +383,7 @@ schema/ Compatibility package (Gait migration) exitcode/ Compatibility package (Gait migration) schemas/v1/ JSON Schema spec files (language-agnostic contract) types/ 18 record type schemas -frameworks/ 8 compliance framework YAML definitions +frameworks/ 10 compliance framework YAML definitions docs/ Supplementary format and interoperability documentation testdata/ Golden vectors and test fixtures scripts/ Test and validation scripts @@ -358,23 +405,53 @@ CI pipelines: main, PR, determinism (cross-platform), CodeQL, nightly (hardening ## Install ```bash -PROOF_VERSION="$(gh release view --repo Clyra-AI/proof --json tagName -q .tagName 2>/dev/null || curl -fsSL https://api.github.com/repos/Clyra-AI/proof/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"tag_name\"])')" +# Verified one-command install (Linux/macOS) +curl -fsSL https://raw.githubusercontent.com/Clyra-AI/proof/main/scripts/install.sh | sh -# From module source at latest published release tag -go install github.com/Clyra-AI/proof/cmd/proof@"${PROOF_VERSION}" +# Optional: pin a specific tag +curl -fsSL https://raw.githubusercontent.com/Clyra-AI/proof/main/scripts/install.sh | sh -s -- --version v1.2.3 +``` -# From release assets -gh release download "${PROOF_VERSION}" -R Clyra-AI/proof -D /tmp/proof-release -cd /tmp/proof-release && sha256sum -c checksums.txt +Homebrew publication path: + +```bash +brew tap Clyra-AI/homebrew-tap +brew install proof ``` -Go module: +Go module and CLI install: ```bash PROOF_VERSION="$(gh release view --repo Clyra-AI/proof --json tagName -q .tagName 2>/dev/null || curl -fsSL https://api.github.com/repos/Clyra-AI/proof/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)[\"tag_name\"])')" +go install github.com/Clyra-AI/proof/cmd/proof@"${PROOF_VERSION}" go get github.com/Clyra-AI/proof@"${PROOF_VERSION}" ``` +Release checksum/signature verification: + +```bash +PROOF_VERSION="$(gh release view --repo Clyra-AI/proof --json tagName -q .tagName)" +gh release download "${PROOF_VERSION}" -R Clyra-AI/proof -D /tmp/proof-release +cd /tmp/proof-release +sha256sum -c checksums.txt + +# Optional if cosign is installed and signature assets are present +cosign verify-blob \ + --certificate checksums.txt.pem \ + --signature checksums.txt.sig \ + checksums.txt +``` + +See [docs/release-distribution.md](docs/release-distribution.md) for full release and Homebrew publication workflow. + +## Project Docs + +- [docs/api-contract.md](docs/api-contract.md) +- [docs/python-integration.md](docs/python-integration.md) +- [CONTRIBUTING.md](CONTRIBUTING.md) +- [SECURITY.md](SECURITY.md) +- [CHANGELOG.md](CHANGELOG.md) + ## License See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c8e7288 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected vulnerabilities privately through GitHub Security Advisories: + +- [Report a vulnerability](https://github.com/Clyra-AI/proof/security/advisories/new) + +Do not open public GitHub issues for active vulnerabilities. + +## What to Include + +- Affected version or commit SHA +- Reproduction steps or proof-of-concept +- Impact assessment +- Any suggested remediation + +## Response Expectations + +- We will acknowledge receipt and triage severity. +- We will coordinate remediation and disclosure timing. +- We will publish fixes and release notes once patches are available. diff --git a/cmd/proof/errors.go b/cmd/proof/errors.go index 61ab91e..81ba2e3 100644 --- a/cmd/proof/errors.go +++ b/cmd/proof/errors.go @@ -2,6 +2,7 @@ package main import ( "github.com/Clyra-AI/proof" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/Clyra-AI/proof/core/exitcode" ) @@ -18,6 +19,18 @@ func newCLIError(code int, msg string) error { } func verificationErrorCode(err error) int { + if typed, ok := coreerr.As(err); ok { + switch typed.Kind { + case coreerr.KindDependencyMissing: + return exitcode.DependencyMiss + case coreerr.KindInvalidInput: + return exitcode.InvalidInput + case coreerr.KindValidation: + return exitcode.PolicyOrSchema + case coreerr.KindVerification: + return exitcode.VerificationErr + } + } if proof.IsDependencyMissing(err) { return exitcode.DependencyMiss } diff --git a/cmd/proof/errors_test.go b/cmd/proof/errors_test.go index e297acc..3677639 100644 --- a/cmd/proof/errors_test.go +++ b/cmd/proof/errors_test.go @@ -3,6 +3,8 @@ package main import ( "testing" + coreerr "github.com/Clyra-AI/proof/core/errors" + "github.com/Clyra-AI/proof/core/exitcode" "github.com/stretchr/testify/require" ) @@ -13,3 +15,10 @@ func TestCLIErrorExitCode(t *testing.T) { require.Equal(t, 6, ec.ExitCode()) require.Equal(t, "bad", err.Error()) } + +func TestVerificationErrorCodeFromTypedError(t *testing.T) { + require.Equal(t, exitcode.InvalidInput, verificationErrorCode(coreerr.New(coreerr.KindInvalidInput, "x", "bad input"))) + require.Equal(t, exitcode.PolicyOrSchema, verificationErrorCode(coreerr.New(coreerr.KindValidation, "x", "bad schema"))) + require.Equal(t, exitcode.VerificationErr, verificationErrorCode(coreerr.New(coreerr.KindVerification, "x", "bad signature"))) + require.Equal(t, exitcode.DependencyMiss, verificationErrorCode(coreerr.New(coreerr.KindDependencyMissing, "x", "missing dep"))) +} diff --git a/core/bundle/bundle.go b/core/bundle/bundle.go new file mode 100644 index 0000000..60dd902 --- /dev/null +++ b/core/bundle/bundle.go @@ -0,0 +1,209 @@ +package bundle + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Clyra-AI/proof/core/canon" + coreerr "github.com/Clyra-AI/proof/core/errors" + "github.com/Clyra-AI/proof/core/schema" + "github.com/Clyra-AI/proof/core/signing" +) + +type ManifestEntry struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type Manifest struct { + Files []ManifestEntry `json:"files"` + AlgoID string `json:"algo_id,omitempty"` + SaltID string `json:"salt_id,omitempty"` + Signatures []signing.Signature `json:"signatures,omitempty"` +} + +type VerifyOpts struct { + VerifySignatures bool + PublicKey signing.PublicKey + Cosign signing.CosignVerifyOpts +} + +const manifestFilename = "manifest.json" + +func Verify(path string, opts VerifyOpts) (*Manifest, error) { + manifest, err := ReadManifest(path) + if err != nil { + return nil, err + } + if err := normalizeAlgorithm(&manifest); err != nil { + return nil, err + } + manifestRaw, err := json.Marshal(manifest) + if err != nil { + return nil, coreerr.Wrap(coreerr.KindInternal, "bundle.marshal_manifest_failed", "marshal bundle manifest", err) + } + if err := schema.ValidateAgainstSchema(manifestRaw, "v1/bundle-manifest-v1.schema.json"); err != nil { + return nil, coreerr.Wrap(coreerr.KindValidation, "bundle.schema_validation_failed", "bundle manifest schema validation failed", err, coreerr.WithPath("v1/bundle-manifest-v1.schema.json")) + } + for _, file := range manifest.Files { + // #nosec G304 -- manifest drives local bundle verification. + data, err := os.ReadFile(filepath.Join(path, file.Path)) + if err != nil { + return nil, err + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + want := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(file.SHA256)), "sha256:") + if got != want { + return nil, coreerr.New(coreerr.KindVerification, "bundle.hash_mismatch", fmt.Sprintf("bundle hash mismatch for %s", file.Path), coreerr.WithPath(file.Path)) + } + } + if opts.VerifySignatures { + if len(manifest.Signatures) == 0 { + return nil, coreerr.New(coreerr.KindVerification, "bundle.signature_missing", "bundle manifest has no signatures") + } + digest, err := ManifestDigest(manifest) + if err != nil { + return nil, err + } + for _, sig := range manifest.Signatures { + switch strings.ToLower(strings.TrimSpace(sig.Alg)) { + case "ed25519": + if len(opts.PublicKey.Public) == 0 { + return nil, coreerr.New(coreerr.KindInvalidInput, "bundle.public_key_required", "public key is required for bundle signature verification", coreerr.WithField("public_key")) + } + if err := signing.VerifyDigest(sig, digest, opts.PublicKey); err != nil { + return nil, err + } + case "cosign": + if err := signing.VerifyDigestCosign(sig, digest, opts.Cosign); err != nil { + return nil, err + } + default: + return nil, coreerr.New(coreerr.KindVerification, "bundle.unsupported_signature_algorithm", fmt.Sprintf("unsupported bundle signature algorithm: %s", sig.Alg), coreerr.WithField("alg")) + } + } + } + return &manifest, nil +} + +func ReadManifest(path string) (Manifest, error) { + manifestPath := filepath.Join(path, manifestFilename) + // #nosec G304 -- caller provides explicit local artifact path. + raw, err := os.ReadFile(manifestPath) + if err != nil { + return Manifest{}, err + } + var manifest Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func WriteManifest(path string, manifest Manifest) error { + manifestPath := filepath.Join(path, manifestFilename) + out, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return coreerr.Wrap(coreerr.KindInternal, "bundle.marshal_manifest_failed", "marshal bundle manifest", err) + } + // #nosec G306 -- bundle manifests are workspace artifacts. + if err := os.WriteFile(manifestPath, out, 0o644); err != nil { + return err + } + return nil +} + +func SignManifest(manifest Manifest, key signing.SigningKey) (Manifest, error) { + if err := normalizeAlgorithm(&manifest); err != nil { + return Manifest{}, err + } + digest, err := ManifestDigest(manifest) + if err != nil { + return Manifest{}, err + } + sig, err := signing.SignDigest(digest, key) + if err != nil { + return Manifest{}, err + } + manifest.Signatures = append(manifest.Signatures, sig) + return manifest, nil +} + +func SignManifestCosign(manifest Manifest, keyPath string) (Manifest, error) { + if err := normalizeAlgorithm(&manifest); err != nil { + return Manifest{}, err + } + digest, err := ManifestDigest(manifest) + if err != nil { + return Manifest{}, err + } + sig, err := signing.SignDigestCosign(digest, keyPath) + if err != nil { + return Manifest{}, err + } + manifest.Signatures = append(manifest.Signatures, sig) + return manifest, nil +} + +func SignFile(path string, key signing.SigningKey) (*Manifest, error) { + manifest, err := ReadManifest(path) + if err != nil { + return nil, err + } + signed, err := SignManifest(manifest, key) + if err != nil { + return nil, err + } + if err := WriteManifest(path, signed); err != nil { + return nil, err + } + return &signed, nil +} + +func SignFileCosign(path string, keyPath string) (*Manifest, error) { + manifest, err := ReadManifest(path) + if err != nil { + return nil, err + } + signed, err := SignManifestCosign(manifest, keyPath) + if err != nil { + return nil, err + } + if err := WriteManifest(path, signed); err != nil { + return nil, err + } + return &signed, nil +} + +func ManifestDigest(manifest Manifest) (string, error) { + m := manifest + m.Signatures = nil + raw, err := json.Marshal(m) + if err != nil { + return "", coreerr.Wrap(coreerr.KindInternal, "bundle.marshal_manifest_failed", "marshal bundle manifest", err) + } + canonical, err := canon.Canonicalize(raw, canon.DomainJSON) + if err != nil { + return "", coreerr.Wrap(coreerr.KindInternal, "bundle.canonicalize_manifest_failed", "canonicalize bundle manifest", err) + } + sum := sha256.Sum256(canonical) + return hex.EncodeToString(sum[:]), nil +} + +func normalizeAlgorithm(manifest *Manifest) error { + algoID := strings.ToLower(strings.TrimSpace(manifest.AlgoID)) + if algoID == "" { + algoID = "sha256" + manifest.AlgoID = algoID + } + if algoID != "sha256" { + return coreerr.New(coreerr.KindValidation, "bundle.unsupported_digest_algorithm", fmt.Sprintf("unsupported bundle digest algorithm: %s", manifest.AlgoID), coreerr.WithField("algo_id")) + } + return nil +} diff --git a/core/bundle/bundle_test.go b/core/bundle/bundle_test.go new file mode 100644 index 0000000..6168e6d --- /dev/null +++ b/core/bundle/bundle_test.go @@ -0,0 +1,75 @@ +package bundle + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Clyra-AI/proof/core/signing" + "github.com/stretchr/testify/require" +) + +func TestSignManifestIsPure(t *testing.T) { + key, err := signing.GenerateKey() + require.NoError(t, err) + + input := Manifest{ + Files: []ManifestEntry{ + { + Path: "records.jsonl", + SHA256: "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356", + }, + }, + } + + signed, err := SignManifest(input, key) + require.NoError(t, err) + require.Empty(t, input.Signatures) + require.Len(t, signed.Signatures, 1) + require.Equal(t, "sha256", signed.AlgoID) +} + +func TestSignFileAndVerify(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "records.jsonl"), []byte("{}\n"), 0o644)) + + manifest := Manifest{ + Files: []ManifestEntry{ + { + Path: "records.jsonl", + SHA256: "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356", + }, + }, + } + raw, err := json.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), raw, 0o644)) + + key, err := signing.GenerateKey() + require.NoError(t, err) + + _, err = SignFile(dir, key) + require.NoError(t, err) + + verified, err := Verify(dir, VerifyOpts{ + VerifySignatures: true, + PublicKey: signing.PublicKey{Public: key.Public}, + }) + require.NoError(t, err) + require.Len(t, verified.Signatures, 1) +} + +func TestVerifyUnsupportedAlgorithm(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "records.jsonl"), []byte("{}\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"algo_id":"sha512","files":[{"path":"records.jsonl","sha256":"sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"}]}`), 0o644)) + + _, err := Verify(dir, VerifyOpts{}) + require.ErrorContains(t, err, "unsupported bundle digest algorithm") +} + +func TestSignManifestCosignRequiresKeyPath(t *testing.T) { + _, err := SignManifestCosign(Manifest{Files: []ManifestEntry{}}, "") + require.ErrorContains(t, err, "cosign key path is required") +} diff --git a/core/errors/errors.go b/core/errors/errors.go new file mode 100644 index 0000000..05e4df2 --- /dev/null +++ b/core/errors/errors.go @@ -0,0 +1,100 @@ +package errors + +import ( + stdliberrors "errors" + "fmt" + "strings" +) + +// Kind classifies library errors for machine-readable handling. +type Kind string + +const ( + KindInvalidInput Kind = "invalid_input" + KindValidation Kind = "validation" + KindVerification Kind = "verification" + KindDependencyMissing Kind = "dependency_missing" + KindInternal Kind = "internal" +) + +// Error is a structured library error with optional field/path context. +type Error struct { + Kind Kind `json:"kind"` + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` + Path string `json:"path,omitempty"` + + cause error +} + +func (e *Error) Error() string { + if e == nil { + return "" + } + message := strings.TrimSpace(e.Message) + switch { + case message == "" && e.cause != nil: + return e.cause.Error() + case message == "": + return string(e.Kind) + case e.cause != nil: + return fmt.Sprintf("%s: %v", message, e.cause) + default: + return message + } +} + +func (e *Error) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +type Option func(*Error) + +func WithField(field string) Option { + return func(e *Error) { + e.Field = strings.TrimSpace(field) + } +} + +func WithPath(path string) Option { + return func(e *Error) { + e.Path = strings.TrimSpace(path) + } +} + +func WithCause(err error) Option { + return func(e *Error) { + e.cause = err + } +} + +func New(kind Kind, code, message string, opts ...Option) error { + e := &Error{ + Kind: kind, + Code: strings.TrimSpace(code), + Message: strings.TrimSpace(message), + } + for _, opt := range opts { + if opt != nil { + opt(e) + } + } + return e +} + +func Wrap(kind Kind, code, message string, cause error, opts ...Option) error { + opts = append(opts, WithCause(cause)) + return New(kind, code, message, opts...) +} + +func As(err error) (*Error, bool) { + var out *Error + if stdliberrors.As(err, &out) { + return out, true + } + return nil, false +} diff --git a/core/framework/aiuc-1.yaml b/core/framework/aiuc-1.yaml new file mode 100644 index 0000000..0bfd52e --- /dev/null +++ b/core/framework/aiuc-1.yaml @@ -0,0 +1,265 @@ +framework: + id: aiuc-1 + version: "2026" + title: AIUC-1 Standard +controls: + - id: A001 + title: Establish input data policy + required_record_types: &aiuc_data_privacy_types [permission_check, policy_enforcement, tool_invocation, incident] + required_fields: &proof_required_fields [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: A002 + title: Establish output data policy + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A003 + title: Limit AI agent data collection + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A004 + title: Protect IP & trade secrets + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A005 + title: Prevent cross-customer data exposure + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A006 + title: Prevent PII leakage + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A007 + title: Prevent IP violations + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: B001 + title: Third-party testing of adversarial robustness + required_record_types: &aiuc_security_types [policy_enforcement, permission_check, guardrail_activation, test_result, incident] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B002 + title: Detect adversarial input + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B003 + title: Manage public release of technical details + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B004 + title: Prevent AI endpoint scraping + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B005 + title: Implement real-time input filtering + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B006 + title: Prevent unauthorized AI agent actions + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B007 + title: Enforce user access privileges to AI systems + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B008 + title: Protect model deployment environment + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B009 + title: Limit output over-exposure + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: C001 + title: Define AI risk taxonomy + required_record_types: &aiuc_safety_types [risk_assessment, guardrail_activation, test_result, human_oversight, incident] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C002 + title: Conduct pre-deployment testing + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C003 + title: Prevent harmful outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C004 + title: Prevent out-of-scope outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C005 + title: Prevent customer-defined high risk outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C006 + title: Prevent output vulnerabilities + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C007 + title: Flag high risk outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C008 + title: Monitor AI risk categories + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C009 + title: Enable real-time feedback and intervention + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C010 + title: Third-party testing for harmful outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C011 + title: Third-party testing for out-of-scope outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C012 + title: Third-party testing for customer-defined risk + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: D001 + title: Prevent hallucinated outputs + required_record_types: &aiuc_reliability_types [test_result, guardrail_activation, tool_invocation, compiled_action] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D002 + title: Third-party testing for hallucinations + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D003 + title: Restrict unsafe tool calls + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D004 + title: Third-party testing of tool calls + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: E001 + title: AI failure plan for security breaches + required_record_types: &aiuc_accountability_types [approval, human_oversight, policy_enforcement, incident, deployment] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E002 + title: AI failure plan for harmful outputs + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E003 + title: AI failure plan for hallucinations + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E004 + title: Assign accountability + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E005 + title: Assess cloud vs on-prem processing + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E006 + title: Conduct vendor due diligence + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E007 + title: Document system change approvals + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E008 + title: Review internal processes + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E009 + title: Monitor third-party access + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E010 + title: Establish AI acceptable use policy + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E011 + title: Record processing locations + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E012 + title: Document regulatory compliance + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E013 + title: Implement quality management system + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E014 + title: Share transparency reports + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E015 + title: Log model activity + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E016 + title: Implement AI disclosure mechanisms + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E017 + title: Document system transparency policy + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: F001 + title: Prevent AI cyber misuse + required_record_types: &aiuc_society_types [guardrail_activation, policy_enforcement, incident, risk_assessment] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: F002 + title: Prevent catastrophic misuse + required_record_types: *aiuc_society_types + required_fields: *proof_required_fields + minimum_frequency: continuous diff --git a/core/framework/owasp-agentic-top-10.yaml b/core/framework/owasp-agentic-top-10.yaml new file mode 100644 index 0000000..b2670b2 --- /dev/null +++ b/core/framework/owasp-agentic-top-10.yaml @@ -0,0 +1,55 @@ +framework: + id: owasp-agentic-top-10 + version: "2025" + title: OWASP Agentic Top 10 +controls: + - id: ASI01 + title: Agent Goal Hijack + required_record_types: &owasp_agentic_types [tool_invocation, policy_enforcement, permission_check, guardrail_activation, incident, risk_assessment] + required_fields: &proof_required_fields [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: ASI02 + title: Tool Misuse & Exploitation + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI03 + title: Identity & Privilege Abuse + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI04 + title: Agentic Supply Chain Vulnerabilities + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI05 + title: Unexpected Code Execution (RCE) + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI06 + title: Memory & Context Poisoning + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI07 + title: Insecure Inter-Agent Communication + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI08 + title: Cascading Failures + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI09 + title: Human-Agent Trust Exploitation + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI10 + title: Rogue Agents + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous diff --git a/core/record/record.go b/core/record/record.go index 32938ad..54ecab3 100644 --- a/core/record/record.go +++ b/core/record/record.go @@ -4,13 +4,13 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "errors" "fmt" "sort" "strings" "time" "github.com/Clyra-AI/proof/core/canon" + coreerr "github.com/Clyra-AI/proof/core/errors" ) const SchemaVersion = "1.0" @@ -54,32 +54,32 @@ func New(opts RecordOpts) (*Record, error) { func Validate(r *Record) error { if r == nil { - return errors.New("record is nil") + return coreerr.New(coreerr.KindInvalidInput, "record.nil", "record is nil", coreerr.WithField("record")) } if strings.TrimSpace(r.RecordVersion) == "" { - return errors.New("record_version is required") + return coreerr.New(coreerr.KindValidation, "record.record_version_required", "record_version is required", coreerr.WithField("record_version")) } if r.Timestamp.IsZero() { - return errors.New("timestamp is required") + return coreerr.New(coreerr.KindValidation, "record.timestamp_required", "timestamp is required", coreerr.WithField("timestamp")) } if strings.TrimSpace(r.Source) == "" { - return errors.New("source is required") + return coreerr.New(coreerr.KindValidation, "record.source_required", "source is required", coreerr.WithField("source")) } if strings.TrimSpace(r.SourceProduct) == "" { - return errors.New("source_product is required") + return coreerr.New(coreerr.KindValidation, "record.source_product_required", "source_product is required", coreerr.WithField("source_product")) } if strings.TrimSpace(r.RecordType) == "" { - return errors.New("record_type is required") + return coreerr.New(coreerr.KindValidation, "record.record_type_required", "record_type is required", coreerr.WithField("record_type")) } if r.Event == nil { - return errors.New("event is required") + return coreerr.New(coreerr.KindValidation, "record.event_required", "event is required", coreerr.WithField("event")) } return nil } func ComputeHash(r *Record) (string, error) { if r == nil { - return "", errors.New("record is nil") + return "", coreerr.New(coreerr.KindInvalidInput, "record.nil", "record is nil", coreerr.WithField("record")) } payload := map[string]any{ "record_id": r.RecordID, @@ -104,11 +104,11 @@ func ComputeHash(r *Record) (string, error) { } raw, err := json.Marshal(payload) if err != nil { - return "", fmt.Errorf("marshal payload: %w", err) + return "", coreerr.Wrap(coreerr.KindInternal, "record.compute_hash_marshal_payload", "marshal payload", err) } canonical, err := canon.Canonicalize(raw, canon.DomainJSON) if err != nil { - return "", fmt.Errorf("canonicalize payload: %w", err) + return "", coreerr.Wrap(coreerr.KindInternal, "record.compute_hash_canonicalize_payload", "canonicalize payload", err) } sum := sha256.Sum256(canonical) return "sha256:" + hex.EncodeToString(sum[:]), nil @@ -124,11 +124,11 @@ func deterministicID(r *Record) (string, error) { "event": r.Event, }) if err != nil { - return "", err + return "", coreerr.Wrap(coreerr.KindInternal, "record.deterministic_id_marshal_payload", "marshal deterministic id payload", err) } canonical, err := canon.Canonicalize(raw, canon.DomainJSON) if err != nil { - return "", err + return "", coreerr.Wrap(coreerr.KindInternal, "record.deterministic_id_canonicalize_payload", "canonicalize deterministic id payload", err) } sum := sha256.Sum256(canonical) prefix := hex.EncodeToString(sum[:])[:8] diff --git a/core/record/record_test.go b/core/record/record_test.go index c9ac222..acbd401 100644 --- a/core/record/record_test.go +++ b/core/record/record_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/stretchr/testify/require" ) @@ -31,6 +32,16 @@ func TestValidateRequiredFields(t *testing.T) { require.Error(t, err) } +func TestValidateTypedError(t *testing.T) { + err := Validate(nil) + require.Error(t, err) + typed, ok := coreerr.As(err) + require.True(t, ok) + require.Equal(t, coreerr.KindInvalidInput, typed.Kind) + require.Equal(t, "record.nil", typed.Code) + require.Equal(t, "record", typed.Field) +} + func TestValidateDetailedErrors(t *testing.T) { err := Validate(nil) require.ErrorContains(t, err, "record is nil") diff --git a/core/schema/schema.go b/core/schema/schema.go index 3b9021e..e44b406 100644 --- a/core/schema/schema.go +++ b/core/schema/schema.go @@ -10,6 +10,8 @@ import ( "sync" jsonschema "github.com/santhosh-tekuri/jsonschema/v5" + + coreerr "github.com/Clyra-AI/proof/core/errors" ) //go:embed v1/*.json v1/types/*.json @@ -60,7 +62,13 @@ func ListRecordTypes() []RecordType { func ValidateRecord(data []byte, recordType string) error { if err := validateWithSchema(data, "v1/proof-record-v1.schema.json"); err != nil { - return err + return coreerr.Wrap( + coreerr.KindValidation, + "schema.record.base_validation_failed", + "record schema validation failed", + err, + coreerr.WithPath("v1/proof-record-v1.schema.json"), + ) } path, ok := schemaPathForType(recordType) if !ok { @@ -68,17 +76,46 @@ func ValidateRecord(data []byte, recordType string) error { customSchema, customOK := customSchemas[recordType] customMu.RUnlock() if !customOK { - return fmt.Errorf("unknown record type: %s", recordType) + return coreerr.New( + coreerr.KindValidation, + "schema.record.unknown_record_type", + fmt.Sprintf("unknown record type: %s", recordType), + coreerr.WithField("record_type"), + ) + } + if err := validateWithCompiledSchema(data, customSchema); err != nil { + return coreerr.Wrap( + coreerr.KindValidation, + "schema.record.custom_type_validation_failed", + "custom record type schema validation failed", + err, + coreerr.WithField("record_type"), + ) } - return validateWithCompiledSchema(data, customSchema) + return nil } - return validateWithSchema(data, path) + if err := validateWithSchema(data, path); err != nil { + return coreerr.Wrap( + coreerr.KindValidation, + "schema.record.type_validation_failed", + "record type schema validation failed", + err, + coreerr.WithPath(path), + ) + } + return nil } func ValidateCustomSchema(schemaPath string, data []byte) error { _, err := compileSchema("custom.json", data) if err != nil { - return fmt.Errorf("compile custom schema %s: %w", filepath.Base(schemaPath), err) + return coreerr.Wrap( + coreerr.KindValidation, + "schema.custom.compile_failed", + fmt.Sprintf("compile custom schema %s", filepath.Base(schemaPath)), + err, + coreerr.WithPath(schemaPath), + ) } return nil } @@ -86,14 +123,30 @@ func ValidateCustomSchema(schemaPath string, data []byte) error { func RegisterCustomType(recordType string, schemaPath string, data []byte) error { recordType = strings.TrimSpace(recordType) if recordType == "" { - return fmt.Errorf("record type is required") + return coreerr.New( + coreerr.KindInvalidInput, + "schema.custom.record_type_required", + "record type is required", + coreerr.WithField("record_type"), + ) } if _, ok := schemaPathForType(recordType); ok { - return fmt.Errorf("record type %s conflicts with built-in type", recordType) + return coreerr.New( + coreerr.KindValidation, + "schema.custom.record_type_conflicts_builtin", + fmt.Sprintf("record type %s conflicts with built-in type", recordType), + coreerr.WithField("record_type"), + ) } s, err := compileSchema("custom.json", data) if err != nil { - return fmt.Errorf("compile custom schema %s: %w", filepath.Base(schemaPath), err) + return coreerr.Wrap( + coreerr.KindValidation, + "schema.custom.compile_failed", + fmt.Sprintf("compile custom schema %s", filepath.Base(schemaPath)), + err, + coreerr.WithPath(schemaPath), + ) } customMu.Lock() customTypes[recordType] = RecordType{ @@ -120,7 +173,7 @@ func ValidateAgainstSchema(data []byte, schemaPath string) error { func validateWithSchema(data []byte, schemaPath string) error { raw, err := schemaFS.ReadFile(schemaPath) if err != nil { - return err + return coreerr.Wrap(coreerr.KindInternal, "schema.read_embedded_schema_failed", "read embedded schema", err, coreerr.WithPath(schemaPath)) } s, err := compileSchema("schema.json", raw) if err != nil { @@ -132,17 +185,24 @@ func validateWithSchema(data []byte, schemaPath string) error { func validateWithCompiledSchema(data []byte, s *jsonschema.Schema) error { var v any if err := json.Unmarshal(data, &v); err != nil { - return err + return coreerr.Wrap(coreerr.KindInvalidInput, "schema.invalid_json", "parse json", err) + } + if err := s.Validate(v); err != nil { + return coreerr.Wrap(coreerr.KindValidation, "schema.validation_failed", "schema validation failed", err) } - return s.Validate(v) + return nil } func compileSchema(name string, raw []byte) (*jsonschema.Schema, error) { compiler := jsonschema.NewCompiler() if err := compiler.AddResource(name, strings.NewReader(string(raw))); err != nil { - return nil, err + return nil, coreerr.Wrap(coreerr.KindValidation, "schema.compile_resource_failed", "compile schema resource", err) + } + compiled, err := compiler.Compile(name) + if err != nil { + return nil, coreerr.Wrap(coreerr.KindValidation, "schema.compile_failed", "compile schema", err) } - return compiler.Compile(name) + return compiled, nil } func schemaPathForType(recordType string) (string, bool) { diff --git a/core/schema/schema_test.go b/core/schema/schema_test.go index 17a6b00..ea76f01 100644 --- a/core/schema/schema_test.go +++ b/core/schema/schema_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/stretchr/testify/require" ) @@ -29,6 +30,25 @@ func TestValidateRecord(t *testing.T) { require.Error(t, ValidateRecord(raw, "unknown_type")) } +func TestValidateRecordTypedError(t *testing.T) { + raw := []byte(`{ + "record_id":"prf-test", + "record_version":"1.0", + "timestamp":"2026-02-17T12:00:00Z", + "source":"axym", + "source_product":"axym", + "record_type":"decision", + "event":{"action":"allow"}, + "controls":{}, + "integrity":{"record_hash":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + }`) + err := ValidateRecord(raw, "unknown_type") + require.Error(t, err) + typed, ok := coreerr.As(err) + require.True(t, ok) + require.Equal(t, coreerr.KindValidation, typed.Kind) +} + func TestValidateCustomSchema(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "custom.schema.json") diff --git a/core/signing/cosign.go b/core/signing/cosign.go index 0f9ea4d..c006717 100644 --- a/core/signing/cosign.go +++ b/core/signing/cosign.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/Clyra-AI/proof/core/record" ) @@ -28,15 +29,18 @@ var cosignRun = func(args ...string) ([]byte, error) { } func IsDependencyMissing(err error) bool { + if typed, ok := coreerr.As(err); ok && typed.Kind == coreerr.KindDependencyMissing { + return true + } return errors.Is(err, ErrDependencyMissing) } func SignRecordCosign(r *record.Record, keyPath string) (*record.Record, error) { if r == nil { - return nil, fmt.Errorf("record is nil") + return nil, coreerr.New(coreerr.KindInvalidInput, "signing.record_nil", "record is nil", coreerr.WithField("record")) } if strings.TrimSpace(keyPath) == "" { - return nil, fmt.Errorf("cosign key path is required") + return nil, coreerr.New(coreerr.KindInvalidInput, "signing.cosign.key_path_required", "cosign key path is required", coreerr.WithField("key_path")) } if r.Integrity.RecordHash == "" { h, err := record.ComputeHash(r) @@ -56,10 +60,15 @@ func SignRecordCosign(r *record.Record, keyPath string) (*record.Record, error) func SignDigestCosign(digest string, keyPath string) (Signature, error) { if strings.TrimSpace(keyPath) == "" { - return Signature{}, fmt.Errorf("cosign key path is required") + return Signature{}, coreerr.New(coreerr.KindInvalidInput, "signing.cosign.key_path_required", "cosign key path is required", coreerr.WithField("key_path")) } if _, err := cosignLookPath("cosign"); err != nil { - return Signature{}, fmt.Errorf("%w: cosign binary not found: %v", ErrDependencyMissing, err) + return Signature{}, coreerr.Wrap( + coreerr.KindDependencyMissing, + "signing.cosign.binary_missing", + "cosign binary not found", + fmt.Errorf("%w: %v", ErrDependencyMissing, err), + ) } tmpDir, err := os.MkdirTemp("", "proof-cosign-") if err != nil { @@ -75,7 +84,11 @@ func SignDigestCosign(digest string, keyPath string) (Signature, error) { args := []string{"sign-blob", "--key", keyPath, "--output-signature", sigPath, blobPath} if out, err := cosignRun(args...); err != nil { - return Signature{}, fmt.Errorf("cosign sign-blob failed: %v (%s)", err, strings.TrimSpace(string(out))) + return Signature{}, coreerr.New( + coreerr.KindVerification, + "signing.cosign.sign_blob_failed", + fmt.Sprintf("cosign sign-blob failed: %v (%s)", err, strings.TrimSpace(string(out))), + ) } // #nosec G304 -- signature path is generated in process-owned temp dir. rawSig, err := os.ReadFile(sigPath) @@ -92,17 +105,22 @@ func SignDigestCosign(digest string, keyPath string) (Signature, error) { func VerifyRecordCosign(r *record.Record, opts CosignVerifyOpts) error { if r == nil { - return fmt.Errorf("record is nil") + return coreerr.New(coreerr.KindInvalidInput, "signing.record_nil", "record is nil", coreerr.WithField("record")) } if !strings.HasPrefix(r.Integrity.Signature, "cosign:") { - return fmt.Errorf("record does not contain cosign signature") + return coreerr.New(coreerr.KindInvalidInput, "signing.cosign.signature_missing", "record does not contain cosign signature", coreerr.WithField("integrity.signature")) } expected, err := record.ComputeHash(r) if err != nil { return err } if expected != r.Integrity.RecordHash { - return fmt.Errorf("record hash mismatch: expected %s got %s", expected, r.Integrity.RecordHash) + return coreerr.New( + coreerr.KindVerification, + "signing.record_hash_mismatch", + fmt.Sprintf("record hash mismatch: expected %s got %s", expected, r.Integrity.RecordHash), + coreerr.WithField("integrity.record_hash"), + ) } sig := Signature{ Alg: "cosign", @@ -115,16 +133,30 @@ func VerifyRecordCosign(r *record.Record, opts CosignVerifyOpts) error { func VerifyDigestCosign(sig Signature, digest string, opts CosignVerifyOpts) error { if strings.TrimSpace(opts.KeyPath) == "" && strings.TrimSpace(opts.CertificatePath) == "" { - return fmt.Errorf("cosign verification requires --cosign-key or --cosign-cert") + return coreerr.New( + coreerr.KindInvalidInput, + "signing.cosign.verify_material_required", + "cosign verification requires --cosign-key or --cosign-cert", + ) } if _, err := cosignLookPath("cosign"); err != nil { - return fmt.Errorf("%w: cosign binary not found: %v", ErrDependencyMissing, err) + return coreerr.Wrap( + coreerr.KindDependencyMissing, + "signing.cosign.binary_missing", + "cosign binary not found", + fmt.Errorf("%w: %v", ErrDependencyMissing, err), + ) } if strings.TrimSpace(sig.SignedDigest) == "" { - return fmt.Errorf("signed digest is required") + return coreerr.New(coreerr.KindInvalidInput, "signing.cosign.signed_digest_required", "signed digest is required", coreerr.WithField("signed_digest")) } if strings.TrimSpace(sig.SignedDigest) != strings.TrimSpace(digest) { - return fmt.Errorf("signed digest mismatch: expected %s got %s", digest, sig.SignedDigest) + return coreerr.New( + coreerr.KindVerification, + "signing.signed_digest_mismatch", + fmt.Sprintf("signed digest mismatch: expected %s got %s", digest, sig.SignedDigest), + coreerr.WithField("signed_digest"), + ) } tmpDir, err := os.MkdirTemp("", "proof-cosign-") if err != nil { @@ -157,7 +189,11 @@ func VerifyDigestCosign(sig Signature, digest string, opts CosignVerifyOpts) err args = append(args, blobPath) if out, err := cosignRun(args...); err != nil { - return fmt.Errorf("cosign verify-blob failed: %v (%s)", err, strings.TrimSpace(string(out))) + return coreerr.New( + coreerr.KindVerification, + "signing.cosign.verify_blob_failed", + fmt.Sprintf("cosign verify-blob failed: %v (%s)", err, strings.TrimSpace(string(out))), + ) } return nil } diff --git a/core/signing/signing.go b/core/signing/signing.go index 90e7f64..2549848 100644 --- a/core/signing/signing.go +++ b/core/signing/signing.go @@ -7,13 +7,13 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "regexp" "strings" "time" "github.com/Clyra-AI/proof/core/canon" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/Clyra-AI/proof/core/record" ) @@ -81,10 +81,10 @@ func NormalizeKeyID(id string, pub ed25519.PublicKey) string { func Sign(r *record.Record, key SigningKey) (*record.Record, error) { if r == nil { - return nil, errors.New("record is nil") + return nil, coreerr.New(coreerr.KindInvalidInput, "signing.record_nil", "record is nil", coreerr.WithField("record")) } if len(key.Private) == 0 { - return nil, errors.New("private key is required") + return nil, coreerr.New(coreerr.KindInvalidInput, "signing.private_key_required", "private key is required", coreerr.WithField("private_key")) } if r.Integrity.RecordHash == "" { h, err := record.ComputeHash(r) @@ -105,10 +105,10 @@ func Sign(r *record.Record, key SigningKey) (*record.Record, error) { func SignDigest(digest string, key SigningKey) (Signature, error) { digest = strings.TrimSpace(digest) if digest == "" { - return Signature{}, errors.New("digest is required") + return Signature{}, coreerr.New(coreerr.KindInvalidInput, "signing.digest_required", "digest is required", coreerr.WithField("digest")) } if len(key.Private) == 0 { - return Signature{}, errors.New("private key is required") + return Signature{}, coreerr.New(coreerr.KindInvalidInput, "signing.private_key_required", "private key is required", coreerr.WithField("private_key")) } if len(key.Public) == 0 { key.Public = key.Private.Public().(ed25519.PublicKey) @@ -124,51 +124,76 @@ func SignDigest(digest string, key SigningKey) (Signature, error) { func VerifyDigest(sig Signature, digest string, pub PublicKey) error { if sig.Alg != "ed25519" { - return fmt.Errorf("unsupported signature algorithm: %s", sig.Alg) + return coreerr.New( + coreerr.KindVerification, + "signing.unsupported_signature_algorithm", + fmt.Sprintf("unsupported signature algorithm: %s", sig.Alg), + coreerr.WithField("alg"), + ) } digest = strings.TrimSpace(digest) if sig.SignedDigest != digest { - return fmt.Errorf("signed digest mismatch: expected %s got %s", digest, sig.SignedDigest) + return coreerr.New( + coreerr.KindVerification, + "signing.signed_digest_mismatch", + fmt.Sprintf("signed digest mismatch: expected %s got %s", digest, sig.SignedDigest), + coreerr.WithField("signed_digest"), + ) } kid := NormalizeKeyID(pub.KeyID, pub.Public) if sig.KeyID != kid { - return fmt.Errorf("signing key mismatch: expected %s got %s", kid, sig.KeyID) + return coreerr.New( + coreerr.KindVerification, + "signing.key_mismatch", + fmt.Sprintf("signing key mismatch: expected %s got %s", kid, sig.KeyID), + coreerr.WithField("key_id"), + ) } decoded, err := base64.StdEncoding.DecodeString(sig.Sig) if err != nil { - return fmt.Errorf("decode signature: %w", err) + return coreerr.Wrap(coreerr.KindInvalidInput, "signing.decode_signature_failed", "decode signature", err, coreerr.WithField("signature")) } if !ed25519.Verify(pub.Public, []byte(digest), decoded) { - return errors.New("signature verification failed") + return coreerr.New(coreerr.KindVerification, "signing.signature_verification_failed", "signature verification failed") } return nil } func Verify(r *record.Record, pub PublicKey) error { if r == nil { - return errors.New("record is nil") + return coreerr.New(coreerr.KindInvalidInput, "signing.record_nil", "record is nil", coreerr.WithField("record")) } if r.Integrity.RecordHash == "" || r.Integrity.Signature == "" || r.Integrity.SigningKeyID == "" { - return errors.New("record integrity signature block is incomplete") + return coreerr.New(coreerr.KindInvalidInput, "signing.integrity_block_incomplete", "record integrity signature block is incomplete", coreerr.WithField("integrity")) } expectedHash, err := record.ComputeHash(r) if err != nil { return err } if expectedHash != r.Integrity.RecordHash { - return fmt.Errorf("record hash mismatch: expected %s got %s", expectedHash, r.Integrity.RecordHash) + return coreerr.New( + coreerr.KindVerification, + "signing.record_hash_mismatch", + fmt.Sprintf("record hash mismatch: expected %s got %s", expectedHash, r.Integrity.RecordHash), + coreerr.WithField("integrity.record_hash"), + ) } kid := NormalizeKeyID(pub.KeyID, pub.Public) if kid != r.Integrity.SigningKeyID { - return fmt.Errorf("signing key mismatch: expected %s got %s", kid, r.Integrity.SigningKeyID) + return coreerr.New( + coreerr.KindVerification, + "signing.key_mismatch", + fmt.Sprintf("signing key mismatch: expected %s got %s", kid, r.Integrity.SigningKeyID), + coreerr.WithField("integrity.signing_key_id"), + ) } enc := strings.TrimPrefix(r.Integrity.Signature, "base64:") sig, err := base64.StdEncoding.DecodeString(enc) if err != nil { - return fmt.Errorf("decode signature: %w", err) + return coreerr.Wrap(coreerr.KindInvalidInput, "signing.decode_signature_failed", "decode signature", err, coreerr.WithField("integrity.signature")) } if !ed25519.Verify(pub.Public, []byte(r.Integrity.RecordHash), sig) { - return errors.New("signature verification failed") + return coreerr.New(coreerr.KindVerification, "signing.signature_verification_failed", "signature verification failed") } return nil } diff --git a/core/signing/signing_test.go b/core/signing/signing_test.go index 85b31a0..adefbe1 100644 --- a/core/signing/signing_test.go +++ b/core/signing/signing_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + coreerr "github.com/Clyra-AI/proof/core/errors" "github.com/Clyra-AI/proof/core/record" "github.com/stretchr/testify/require" ) @@ -80,6 +81,19 @@ func TestSignAndVerifyDigest(t *testing.T) { require.NoError(t, err) } +func TestVerifyDigestTypedError(t *testing.T) { + key, err := GenerateKey() + require.NoError(t, err) + sig, err := SignDigest("abcd1234", key) + require.NoError(t, err) + + err = VerifyDigest(sig, "different", PublicKey{Public: key.Public}) + require.Error(t, err) + typed, ok := coreerr.As(err) + require.True(t, ok) + require.Equal(t, coreerr.KindVerification, typed.Kind) +} + func TestGenerateKeyErrorBranch(t *testing.T) { orig := crand.Reader t.Cleanup(func() { crand.Reader = orig }) diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..95e17a6 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,28 @@ +# API Contract + +This document defines the import-path stability contract for `github.com/Clyra-AI/proof`. +Use this contract before refactors to avoid accidental breakage for downstream users. + +## Stability Matrix + +| Import Path | Scope | Status | Compatibility Commitment | +|---|---|---|---| +| `github.com/Clyra-AI/proof` | Primary public library API | Supported (stable) | Backward compatible within major version. Preferred path for all new integrations. | +| `github.com/Clyra-AI/proof/core/record` | Low-level record primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/chain` | Low-level chain primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/signing` | Low-level signing primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/canon` | Low-level canonicalization primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/schema` | Low-level schema/type primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/framework` | Framework definition loading | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/bundle` | Bundle manifest/sign/verify primitives | Supported (stable) | Backward compatible within major version for exported symbols. | +| `github.com/Clyra-AI/proof/core/exitcode` | Exit-code constants | Supported (stable) | Exit code values `0-8` are contractually stable. | +| `github.com/Clyra-AI/proof/signing` | Compatibility shim | Supported (compatibility) | Kept for migration compatibility. New code should prefer `github.com/Clyra-AI/proof` or `.../core/signing`. | +| `github.com/Clyra-AI/proof/schema` | Compatibility shim | Supported (compatibility) | Kept for migration compatibility. New code should prefer `github.com/Clyra-AI/proof` or `.../core/schema`. | +| `github.com/Clyra-AI/proof/canon` | Compatibility shim | Supported (compatibility) | Kept for migration compatibility. New code should prefer `github.com/Clyra-AI/proof` or `.../core/canon`. | +| `github.com/Clyra-AI/proof/exitcode` | Compatibility shim | Supported (compatibility) | Kept for migration compatibility. New code should prefer `.../core/exitcode`. | + +## Deprecation Policy + +- Compatibility shims are not scheduled for removal in major version `1`. +- Any removal or incompatible change requires a major version bump and migration notes. +- Deprecated APIs remain behavior-compatible while their replacements are available. diff --git a/docs/python-integration.md b/docs/python-integration.md new file mode 100644 index 0000000..9353f3f --- /dev/null +++ b/docs/python-integration.md @@ -0,0 +1,74 @@ +# Python Integration Guide + +Proof is JSON-native, so Python integrations should emit artifacts as JSON and call the Proof CLI for verification at CI/runtime boundaries. + +## 1. Produce a JSON Artifact + +Create a record JSON file in Python that follows `schemas/v1/proof-record-v1.schema.json` and a valid record type schema. + +```python +import json +from datetime import datetime, timezone + +record = { + "record_id": "prf-2026-03-03T12:00:00Z-example", + "record_version": "1.0", + "timestamp": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "source": "python-worker", + "source_product": "example-service", + "record_type": "decision", + "event": {"action": "allow"}, + "integrity": {"record_hash": "sha256:..."} +} + +with open("record.json", "w", encoding="utf-8") as f: + json.dump(record, f, indent=2) +``` + +## 2. Verify with the CLI + +Use the CLI as the verification boundary: + +```bash +proof verify --json record.json +``` + +For signature checks: + +```bash +proof verify --json --signatures --public-key record.json +``` + +## 3. Consume Schemas in Python Tooling + +- Base schema: `schemas/v1/proof-record-v1.schema.json` +- Type-specific schemas: `schemas/v1/types/*.schema.json` + +Recommended pattern: + +1. Validate JSON in Python before writing artifacts. +2. Re-verify with `proof verify` in CI and deployment gates. + +## 4. Exit-Code Behavior for Automation + +Use Proof exit codes directly in Python subprocess orchestration: + +- `0`: success +- `1`: internal/runtime error +- `2`: verification failure +- `3`: policy/schema violation +- `4`: approval required +- `5`: regression drift detected +- `6`: invalid input +- `7`: dependency missing +- `8`: unsafe operation blocked + +Example: + +```python +import subprocess + +proc = subprocess.run(["proof", "verify", "--json", "record.json"], capture_output=True, text=True) +if proc.returncode != 0: + raise RuntimeError(f"proof verify failed (exit={proc.returncode}): {proc.stderr}") +``` diff --git a/docs/release-distribution.md b/docs/release-distribution.md new file mode 100644 index 0000000..457cddd --- /dev/null +++ b/docs/release-distribution.md @@ -0,0 +1,51 @@ +# Release Distribution + +This document describes release artifact verification and Homebrew publication for `proof`. + +## Release Artifacts + +Each release is expected to include: + +- Cross-platform archives (`linux`, `darwin`, `windows`; `amd64`, `arm64`) +- `checksums.txt` +- `checksums.txt.sig` and `checksums.txt.pem` (cosign signature + certificate) +- SBOM and provenance artifacts (from CI release workflow) + +## Verify Downloaded Artifacts + +```bash +PROOF_VERSION="vX.Y.Z" +gh release download "${PROOF_VERSION}" -R Clyra-AI/proof -D /tmp/proof-release +cd /tmp/proof-release +sha256sum -c checksums.txt +``` + +If `cosign` is installed and checksum signature assets are present: + +```bash +cosign verify-blob \ + --certificate checksums.txt.pem \ + --signature checksums.txt.sig \ + checksums.txt +``` + +## Homebrew Publication Path + +GoReleaser is configured to publish a formula to `Clyra-AI/homebrew-tap`. + +Required secret for release automation: + +- `HOMEBREW_TAP_GITHUB_TOKEN` with permission to push to the tap repository. + +Configured in `.goreleaser.yaml` under `brews`: + +- formula name: `proof` +- tap repository: `Clyra-AI/homebrew-tap` +- formula directory: `Formula` + +End-user install path: + +```bash +brew tap Clyra-AI/homebrew-tap +brew install proof +``` diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..0f2f851 --- /dev/null +++ b/errors.go @@ -0,0 +1,18 @@ +package proof + +import coreerr "github.com/Clyra-AI/proof/core/errors" + +type ErrorKind = coreerr.Kind +type LibraryError = coreerr.Error + +const ( + ErrorKindInvalidInput = coreerr.KindInvalidInput + ErrorKindValidation = coreerr.KindValidation + ErrorKindVerification = coreerr.KindVerification + ErrorKindDependencyMissing = coreerr.KindDependencyMissing + ErrorKindInternal = coreerr.KindInternal +) + +func AsLibraryError(err error) (*LibraryError, bool) { + return coreerr.As(err) +} diff --git a/frameworks/aiuc-1.yaml b/frameworks/aiuc-1.yaml new file mode 100644 index 0000000..0bfd52e --- /dev/null +++ b/frameworks/aiuc-1.yaml @@ -0,0 +1,265 @@ +framework: + id: aiuc-1 + version: "2026" + title: AIUC-1 Standard +controls: + - id: A001 + title: Establish input data policy + required_record_types: &aiuc_data_privacy_types [permission_check, policy_enforcement, tool_invocation, incident] + required_fields: &proof_required_fields [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: A002 + title: Establish output data policy + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A003 + title: Limit AI agent data collection + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A004 + title: Protect IP & trade secrets + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A005 + title: Prevent cross-customer data exposure + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A006 + title: Prevent PII leakage + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: A007 + title: Prevent IP violations + required_record_types: *aiuc_data_privacy_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: B001 + title: Third-party testing of adversarial robustness + required_record_types: &aiuc_security_types [policy_enforcement, permission_check, guardrail_activation, test_result, incident] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B002 + title: Detect adversarial input + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B003 + title: Manage public release of technical details + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B004 + title: Prevent AI endpoint scraping + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B005 + title: Implement real-time input filtering + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B006 + title: Prevent unauthorized AI agent actions + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B007 + title: Enforce user access privileges to AI systems + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B008 + title: Protect model deployment environment + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: B009 + title: Limit output over-exposure + required_record_types: *aiuc_security_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: C001 + title: Define AI risk taxonomy + required_record_types: &aiuc_safety_types [risk_assessment, guardrail_activation, test_result, human_oversight, incident] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C002 + title: Conduct pre-deployment testing + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C003 + title: Prevent harmful outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C004 + title: Prevent out-of-scope outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C005 + title: Prevent customer-defined high risk outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C006 + title: Prevent output vulnerabilities + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C007 + title: Flag high risk outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C008 + title: Monitor AI risk categories + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C009 + title: Enable real-time feedback and intervention + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C010 + title: Third-party testing for harmful outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C011 + title: Third-party testing for out-of-scope outputs + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: C012 + title: Third-party testing for customer-defined risk + required_record_types: *aiuc_safety_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: D001 + title: Prevent hallucinated outputs + required_record_types: &aiuc_reliability_types [test_result, guardrail_activation, tool_invocation, compiled_action] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D002 + title: Third-party testing for hallucinations + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D003 + title: Restrict unsafe tool calls + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: D004 + title: Third-party testing of tool calls + required_record_types: *aiuc_reliability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: E001 + title: AI failure plan for security breaches + required_record_types: &aiuc_accountability_types [approval, human_oversight, policy_enforcement, incident, deployment] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E002 + title: AI failure plan for harmful outputs + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E003 + title: AI failure plan for hallucinations + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E004 + title: Assign accountability + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E005 + title: Assess cloud vs on-prem processing + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E006 + title: Conduct vendor due diligence + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E007 + title: Document system change approvals + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E008 + title: Review internal processes + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E009 + title: Monitor third-party access + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E010 + title: Establish AI acceptable use policy + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E011 + title: Record processing locations + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E012 + title: Document regulatory compliance + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E013 + title: Implement quality management system + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E014 + title: Share transparency reports + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E015 + title: Log model activity + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E016 + title: Implement AI disclosure mechanisms + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: E017 + title: Document system transparency policy + required_record_types: *aiuc_accountability_types + required_fields: *proof_required_fields + minimum_frequency: continuous + + - id: F001 + title: Prevent AI cyber misuse + required_record_types: &aiuc_society_types [guardrail_activation, policy_enforcement, incident, risk_assessment] + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: F002 + title: Prevent catastrophic misuse + required_record_types: *aiuc_society_types + required_fields: *proof_required_fields + minimum_frequency: continuous diff --git a/frameworks/owasp-agentic-top-10.yaml b/frameworks/owasp-agentic-top-10.yaml new file mode 100644 index 0000000..b2670b2 --- /dev/null +++ b/frameworks/owasp-agentic-top-10.yaml @@ -0,0 +1,55 @@ +framework: + id: owasp-agentic-top-10 + version: "2025" + title: OWASP Agentic Top 10 +controls: + - id: ASI01 + title: Agent Goal Hijack + required_record_types: &owasp_agentic_types [tool_invocation, policy_enforcement, permission_check, guardrail_activation, incident, risk_assessment] + required_fields: &proof_required_fields [record_id, timestamp, source, source_product, record_type, event, integrity.record_hash] + minimum_frequency: continuous + - id: ASI02 + title: Tool Misuse & Exploitation + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI03 + title: Identity & Privilege Abuse + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI04 + title: Agentic Supply Chain Vulnerabilities + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI05 + title: Unexpected Code Execution (RCE) + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI06 + title: Memory & Context Poisoning + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI07 + title: Insecure Inter-Agent Communication + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI08 + title: Cascading Failures + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI09 + title: Human-Agent Trust Exploitation + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous + - id: ASI10 + title: Rogue Agents + required_record_types: *owasp_agentic_types + required_fields: *proof_required_fields + minimum_frequency: continuous diff --git a/proof.go b/proof.go index 38b7afd..35d53eb 100644 --- a/proof.go +++ b/proof.go @@ -1,15 +1,12 @@ package proof import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "os" - "path/filepath" - "strings" "time" + "github.com/Clyra-AI/proof/core/bundle" "github.com/Clyra-AI/proof/core/canon" "github.com/Clyra-AI/proof/core/chain" "github.com/Clyra-AI/proof/core/framework" @@ -40,24 +37,9 @@ type Framework = framework.Framework type RecordType = schema.RecordType type CanonDomain = canon.Domain type Digest = canon.Digest - -type BundleManifestEntry struct { - Path string `json:"path"` - SHA256 string `json:"sha256"` -} - -type BundleManifest struct { - Files []BundleManifestEntry `json:"files"` - AlgoID string `json:"algo_id,omitempty"` - SaltID string `json:"salt_id,omitempty"` - Signatures []Signature `json:"signatures,omitempty"` -} - -type BundleVerifyOpts struct { - VerifySignatures bool - PublicKey PublicKey - Cosign CosignVerifyOpts -} +type BundleManifestEntry = bundle.ManifestEntry +type BundleManifest = bundle.Manifest +type BundleVerifyOpts = bundle.VerifyOpts const ( DomainJSON = canon.DomainJSON @@ -163,6 +145,17 @@ func ReadRecord(path string) (*Record, error) { return &r, nil } +func ReadAndValidateRecord(path string) (*Record, error) { + r, err := ReadRecord(path) + if err != nil { + return nil, err + } + if err := ValidateRecord(r); err != nil { + return nil, err + } + return r, nil +} + func ValidateCustomTypeSchema(schemaPath string) error { // #nosec G304 -- schema path is explicit user input for validation. raw, err := os.ReadFile(schemaPath) @@ -248,139 +241,43 @@ func IsDependencyMissing(err error) bool { } func VerifyBundle(path string, opts BundleVerifyOpts) (*BundleManifest, error) { - manifestPath := filepath.Join(path, "manifest.json") - // #nosec G304 -- caller provides explicit local artifact path. - raw, err := os.ReadFile(manifestPath) + return bundle.Verify(path, opts) +} + +func SignBundleManifest(manifest BundleManifest, key SigningKey) (*BundleManifest, error) { + signed, err := bundle.SignManifest(manifest, key) if err != nil { return nil, err } - var manifest BundleManifest - if err := json.Unmarshal(raw, &manifest); err != nil { - return nil, err - } - algoID := strings.ToLower(strings.TrimSpace(manifest.AlgoID)) - if algoID == "" { - algoID = "sha256" - manifest.AlgoID = algoID - } - if algoID != "sha256" { - return nil, fmt.Errorf("unsupported bundle digest algorithm: %s", manifest.AlgoID) - } - manifestRaw, err := json.Marshal(manifest) + return &signed, nil +} + +func SignBundleManifestCosign(manifest BundleManifest, keyPath string) (*BundleManifest, error) { + signed, err := bundle.SignManifestCosign(manifest, keyPath) if err != nil { return nil, err } - if err := schema.ValidateAgainstSchema(manifestRaw, "v1/bundle-manifest-v1.schema.json"); err != nil { - return nil, fmt.Errorf("bundle manifest schema validation failed: %w", err) - } - for _, file := range manifest.Files { - // #nosec G304 -- manifest drives local bundle verification. - data, err := os.ReadFile(filepath.Join(path, file.Path)) - if err != nil { - return nil, err - } - sum := sha256.Sum256(data) - got := hex.EncodeToString(sum[:]) - want := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(file.SHA256)), "sha256:") - if got != want { - return nil, fmt.Errorf("bundle hash mismatch for %s", file.Path) - } - } - if opts.VerifySignatures { - if len(manifest.Signatures) == 0 { - return nil, fmt.Errorf("bundle manifest has no signatures") - } - digest, err := bundleManifestDigest(manifest) - if err != nil { - return nil, err - } - for _, sig := range manifest.Signatures { - switch strings.ToLower(strings.TrimSpace(sig.Alg)) { - case "ed25519": - if len(opts.PublicKey.Public) == 0 { - return nil, fmt.Errorf("public key is required for bundle signature verification") - } - if err := signing.VerifyDigest(sig, digest, opts.PublicKey); err != nil { - return nil, err - } - case "cosign": - if err := signing.VerifyDigestCosign(sig, digest, opts.Cosign); err != nil { - return nil, err - } - default: - return nil, fmt.Errorf("unsupported bundle signature algorithm: %s", sig.Alg) - } - } - } - return &manifest, nil + return &signed, nil +} + +func SignBundleFile(path string, key SigningKey) (*BundleManifest, error) { + return bundle.SignFile(path, key) } +func SignBundleCosignFile(path string, keyPath string) (*BundleManifest, error) { + return bundle.SignFileCosign(path, keyPath) +} + +// Deprecated: SignBundle mutates /manifest.json. +// Use SignBundleManifest for pure signing or SignBundleFile for explicit file mutation. func SignBundle(path string, key SigningKey) (*BundleManifest, error) { - manifestPath := filepath.Join(path, "manifest.json") - // #nosec G304 -- caller provides explicit local artifact path. - raw, err := os.ReadFile(manifestPath) - if err != nil { - return nil, err - } - var manifest BundleManifest - if err := json.Unmarshal(raw, &manifest); err != nil { - return nil, err - } - if strings.TrimSpace(manifest.AlgoID) == "" { - manifest.AlgoID = "sha256" - } - digest, err := bundleManifestDigest(manifest) - if err != nil { - return nil, err - } - sig, err := signing.SignDigest(digest, key) - if err != nil { - return nil, err - } - manifest.Signatures = append(manifest.Signatures, sig) - out, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return nil, err - } - // #nosec G306 -- bundle manifests are workspace artifacts. - if err := os.WriteFile(manifestPath, out, 0o644); err != nil { - return nil, err - } - return &manifest, nil + return SignBundleFile(path, key) } +// Deprecated: SignBundleCosign mutates /manifest.json. +// Use SignBundleManifestCosign for pure signing or SignBundleCosignFile for explicit file mutation. func SignBundleCosign(path string, keyPath string) (*BundleManifest, error) { - manifestPath := filepath.Join(path, "manifest.json") - // #nosec G304 -- caller provides explicit local artifact path. - raw, err := os.ReadFile(manifestPath) - if err != nil { - return nil, err - } - var manifest BundleManifest - if err := json.Unmarshal(raw, &manifest); err != nil { - return nil, err - } - if strings.TrimSpace(manifest.AlgoID) == "" { - manifest.AlgoID = "sha256" - } - digest, err := bundleManifestDigest(manifest) - if err != nil { - return nil, err - } - sig, err := signing.SignDigestCosign(digest, keyPath) - if err != nil { - return nil, err - } - manifest.Signatures = append(manifest.Signatures, sig) - out, err := json.MarshalIndent(manifest, "", " ") - if err != nil { - return nil, err - } - // #nosec G306 -- bundle manifests are workspace artifacts. - if err := os.WriteFile(manifestPath, out, 0o644); err != nil { - return nil, err - } - return &manifest, nil + return SignBundleCosignFile(path, keyPath) } func chainDigest(c *Chain) (string, error) { @@ -397,18 +294,3 @@ func chainDigest(c *Chain) (string, error) { } return canon.DigestHex(raw, canon.DomainJSON) } - -func bundleManifestDigest(manifest BundleManifest) (string, error) { - m := manifest - m.Signatures = nil - raw, err := json.Marshal(m) - if err != nil { - return "", err - } - canonical, err := canon.Canonicalize(raw, canon.DomainJSON) - if err != nil { - return "", err - } - sum := sha256.Sum256(canonical) - return hex.EncodeToString(sum[:]), nil -} diff --git a/proof_test.go b/proof_test.go index 9569921..10fd65a 100644 --- a/proof_test.go +++ b/proof_test.go @@ -189,6 +189,10 @@ func TestWriteReadAndCustomSchemaValidation(t *testing.T) { require.NoError(t, err) require.Equal(t, r.RecordID, read.RecordID) + validated, err := ReadAndValidateRecord(p) + require.NoError(t, err) + require.Equal(t, r.RecordID, validated.RecordID) + schemaPath := filepath.Join(t.TempDir(), "custom.schema.json") require.NoError(t, os.WriteFile(schemaPath, []byte(`{"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}`), 0o644)) require.NoError(t, ValidateCustomTypeSchema(schemaPath)) @@ -219,6 +223,14 @@ func TestWriteReadAndCustomSchemaValidation(t *testing.T) { require.Equal(t, "vendor.custom_event", customRecord.RecordType) } +func TestReadAndValidateRecordRejectsInvalidSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "record.json") + require.NoError(t, os.WriteFile(path, []byte(`{"record_type":"decision"}`), 0o644)) + + _, err := ReadAndValidateRecord(path) + require.Error(t, err) +} + func TestRevocationListAPI(t *testing.T) { key, err := GenerateSigningKey() require.NoError(t, err) @@ -300,6 +312,29 @@ func TestBundleSignAndVerify(t *testing.T) { require.NoError(t, err) } +func TestSignBundleManifestPure(t *testing.T) { + manifest := BundleManifest{ + Files: []BundleManifestEntry{ + {Path: "records.jsonl", SHA256: "sha256:ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356"}, + }, + } + key, err := GenerateSigningKey() + require.NoError(t, err) + + signed, err := SignBundleManifest(manifest, key) + require.NoError(t, err) + require.Empty(t, manifest.Signatures) + require.Len(t, signed.Signatures, 1) +} + +func TestAsLibraryError(t *testing.T) { + _, err := SignBundleManifest(BundleManifest{}, SigningKey{}) + require.Error(t, err) + typed, ok := AsLibraryError(err) + require.True(t, ok) + require.Equal(t, ErrorKindInvalidInput, typed.Kind) +} + func TestRegisterCustomTypeInline(t *testing.T) { ResetCustomTypes() t.Cleanup(ResetCustomTypes) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..9133f26 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="Clyra-AI/proof" +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" +VERSION="" + +usage() { + cat <<'USAGE' +Usage: install.sh [--version ] [--install-dir ] + +Options: + --version, -v Install a specific release tag (for example: v1.2.3) + --install-dir Target directory for the proof binary (default: $HOME/.local/bin) +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version|-v) + [[ $# -ge 2 ]] || { echo "error: --version requires a value" >&2; exit 2; } + VERSION="$2" + shift 2 + ;; + --install-dir) + [[ $# -ge 2 ]] || { echo "error: --install-dir requires a value" >&2; exit 2; } + INSTALL_DIR="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage + exit 2 + ;; + esac +done + +uname_s="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "${uname_s}" in + darwin) os="darwin" ;; + linux) os="linux" ;; + *) + echo "error: unsupported OS: ${uname_s}" >&2 + exit 1 + ;; +esac + +uname_m="$(uname -m)" +case "${uname_m}" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "error: unsupported architecture: ${uname_m}" >&2 + exit 1 + ;; +esac + +if [[ -z "${VERSION}" ]]; then + VERSION="$( + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n1 + )" + if [[ -z "${VERSION}" ]]; then + echo "error: unable to determine latest release tag" >&2 + exit 1 + fi +fi + +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT + +base_url="https://github.com/${REPO}/releases/download/${VERSION}" +checksums_path="${workdir}/checksums.txt" + +curl -fsSL "${base_url}/checksums.txt" -o "${checksums_path}" + +asset_name="$( + awk '{print $2}' "${checksums_path}" \ + | grep -E "_${os}_${arch}\\.(tar\\.gz|zip)$" \ + | head -n1 || true +)" + +if [[ -z "${asset_name}" ]]; then + echo "error: no release asset found for ${os}/${arch} in ${VERSION}" >&2 + exit 1 +fi + +asset_path="${workdir}/${asset_name}" +curl -fsSL "${base_url}/${asset_name}" -o "${asset_path}" + +expected_line="$(grep -E "[[:space:]]${asset_name}\$" "${checksums_path}" || true)" +if [[ -z "${expected_line}" ]]; then + echo "error: missing checksum line for ${asset_name}" >&2 + exit 1 +fi + +if command -v sha256sum >/dev/null 2>&1; then + ( cd "${workdir}" && printf '%s\n' "${expected_line}" | sha256sum -c - ) +elif command -v shasum >/dev/null 2>&1; then + expected_sum="$(printf '%s\n' "${expected_line}" | awk '{print $1}')" + actual_sum="$(shasum -a 256 "${asset_path}" | awk '{print $1}')" + if [[ "${expected_sum}" != "${actual_sum}" ]]; then + echo "error: checksum verification failed for ${asset_name}" >&2 + exit 1 + fi +else + echo "error: neither sha256sum nor shasum is available for checksum verification" >&2 + exit 1 +fi + +if [[ "${asset_name}" == *.zip ]]; then + unzip -q "${asset_path}" -d "${workdir}/extract" +else + mkdir -p "${workdir}/extract" + tar -xzf "${asset_path}" -C "${workdir}/extract" +fi + +binary_path="$( + find "${workdir}/extract" -type f -name proof -perm -u+x | head -n1 || true +)" +if [[ -z "${binary_path}" ]]; then + echo "error: proof binary not found in extracted archive" >&2 + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" +install -m 0755 "${binary_path}" "${INSTALL_DIR}/proof" + +sig_path="${workdir}/checksums.txt.sig" +cert_path="${workdir}/checksums.txt.pem" +if curl -fsSL "${base_url}/checksums.txt.sig" -o "${sig_path}" && \ + curl -fsSL "${base_url}/checksums.txt.pem" -o "${cert_path}"; then + if command -v cosign >/dev/null 2>&1; then + cosign verify-blob \ + --certificate "${cert_path}" \ + --signature "${sig_path}" \ + "${checksums_path}" >/dev/null + echo "verified: checksums.txt signature" + else + echo "note: cosign not found; skipped signature verification for checksums.txt" + fi +fi + +echo "installed: ${INSTALL_DIR}/proof (${VERSION}, ${os}/${arch})" +case ":${PATH}:" in + *:"${INSTALL_DIR}":*) ;; + *) echo "note: add ${INSTALL_DIR} to PATH to run 'proof' directly" ;; +esac