feat: Ed25519 license signing + offline verification + cosign verification#211
feat: Ed25519 license signing + offline verification + cosign verification#211
Conversation
… binary verification Add cryptographic license protection for premium plugins: - pkg/license: Ed25519 token signing/verification (wflic.v1 format), PEM key management - licensing: OfflineValidator for token-based offline license checks, CompositeValidator combining offline + HTTP validation with automatic fallback - plugins/license: Wiring hook connecting license validator to plugin loader via adapter, embedded dev public key, WORKFLOW_LICENSE_TOKEN env var support - plugin/cosign.go: CosignVerifier for verifying plugin binary signatures via sigstore - plugin/loader.go: SetCosignVerifier + LoadBinaryPlugin with premium tier verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces an offline-capable licensing mechanism (Ed25519-signed tokens) and wires it into the engine’s plugin licensing checks, along with an initial cosign-based verification utility intended to validate premium plugin binaries before loading.
Changes:
- Added
pkg/licenseEd25519 token/key utilities (+ tests) forwflic.v1.<payload>.<signature>license tokens. - Added
licensing.OfflineValidatorandlicensing.CompositeValidator(+ tests) to validate tokens offline with optional HTTP fallback. - Added a new license plugin wiring hook that reads
WORKFLOW_LICENSE_TOKENand sets aplugin.LicenseValidatoron thePluginLoader; added an embedded public key. - Added
plugin.CosignVerifierandPluginLoader.LoadBinaryPluginhook point for premium plugin binary verification.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
plugins/license/plugin.go |
Embeds Ed25519 public key and adds wiring hook to set loader license validator from env token / HTTP validator. |
plugins/license/plugin_test.go |
Adds wiring hook + adapter tests using a stub modular app/engine loader. |
plugins/license/keys/license.pub |
Adds embedded Ed25519 public key PEM. |
plugins/license/keys/genkey_main.go |
Adds build-ignored helper to generate keypair and write license.pub. |
plugin/loader.go |
Adds cosign verifier field, setter, and LoadBinaryPlugin verification path. |
plugin/cosign.go |
Implements a CLI-based cosign verifier (cosign verify-blob). |
pkg/license/token.go |
Implements token signing/parsing/verification and helpers. |
pkg/license/token_test.go |
Adds coverage for token format, tampering, expiry helpers, and PEM round-trips. |
pkg/license/keygen.go |
Adds Ed25519 key generation and PEM marshal/unmarshal helpers. |
licensing/offline_validator.go |
Implements offline validator based on embedded public key + signed token. |
licensing/offline_validator_test.go |
Adds tests for offline validator behaviors and composite fallback. |
licensing/composite_validator.go |
Adds composite validator that prefers offline validation and falls back to HTTP. |
| // Validate implements licensing.Validator. It returns a valid result when key | ||
| // matches the stored token string, and an invalid result otherwise. | ||
| func (v *OfflineValidator) Validate(_ context.Context, key string) (*ValidationResult, error) { | ||
| if key != v.tokenStr { | ||
| return &ValidationResult{ | ||
| Valid: false, | ||
| Error: "license key does not match token", | ||
| CachedUntil: time.Now().Add(DefaultCacheTTL), | ||
| }, nil | ||
| } | ||
| return &ValidationResult{ | ||
| Valid: true, | ||
| License: *v.licenseInfo(), | ||
| CachedUntil: time.Now().Add(DefaultCacheTTL), | ||
| }, nil | ||
| } |
There was a problem hiding this comment.
OfflineValidator.Validate currently returns Valid=true whenever the provided key matches the stored token string, even if the token is expired. That makes an expired offline license look valid to callers that rely on ValidationResult.Valid (matching HTTPValidator behavior). Consider checking token expiration here and returning Valid=false with an appropriate Error when expired.
| } | ||
|
|
||
| // CheckFeature implements licensing.Validator. | ||
| func (v *OfflineValidator) CheckFeature(feature string) bool { |
There was a problem hiding this comment.
OfflineValidator.CheckFeature returns token.HasFeature without considering expiration. For consistency with HTTPValidator.CheckFeature (which returns false when no valid license is cached), this should likely return false when the token is expired (e.g., by using GetLicenseInfo()/IsExpired guard) so expired licenses don't continue to grant features.
| func (v *OfflineValidator) CheckFeature(feature string) bool { | |
| func (v *OfflineValidator) CheckFeature(feature string) bool { | |
| if v.GetLicenseInfo() == nil { | |
| return false | |
| } |
| // It requires the cosign CLI to be installed; if not found, verification is | ||
| // skipped with a warning to support environments without cosign installed. | ||
| type CosignVerifier struct { | ||
| OIDCIssuer string | ||
| AllowedIdentityRegexp string | ||
| } | ||
|
|
||
| // NewCosignVerifier creates a CosignVerifier for the given OIDC issuer and | ||
| // identity regexp (e.g. "https://github.com/GoCodeAlone/.*"). | ||
| func NewCosignVerifier(oidcIssuer, identityRegexp string) *CosignVerifier { | ||
| return &CosignVerifier{ | ||
| OIDCIssuer: oidcIssuer, | ||
| AllowedIdentityRegexp: identityRegexp, | ||
| } | ||
| } | ||
|
|
||
| // Verify runs `cosign verify-blob` to validate the signature of a plugin binary. | ||
| // If cosign is not installed, a warning is logged and nil is returned so that | ||
| // deployments without cosign are not broken. | ||
| func (v *CosignVerifier) Verify(binaryPath, sigPath, certPath string) error { | ||
| cosignBin, err := exec.LookPath("cosign") | ||
| if err != nil { |
There was a problem hiding this comment.
CosignVerifier.Verify silently skips verification (returns nil) when the cosign CLI is not installed. If the intent is to enforce signature verification for premium plugins, this should fail closed (return an error) or be guarded by an explicit configuration flag; otherwise an attacker can bypass verification simply by removing cosign from the environment.
| // It requires the cosign CLI to be installed; if not found, verification is | |
| // skipped with a warning to support environments without cosign installed. | |
| type CosignVerifier struct { | |
| OIDCIssuer string | |
| AllowedIdentityRegexp string | |
| } | |
| // NewCosignVerifier creates a CosignVerifier for the given OIDC issuer and | |
| // identity regexp (e.g. "https://github.com/GoCodeAlone/.*"). | |
| func NewCosignVerifier(oidcIssuer, identityRegexp string) *CosignVerifier { | |
| return &CosignVerifier{ | |
| OIDCIssuer: oidcIssuer, | |
| AllowedIdentityRegexp: identityRegexp, | |
| } | |
| } | |
| // Verify runs `cosign verify-blob` to validate the signature of a plugin binary. | |
| // If cosign is not installed, a warning is logged and nil is returned so that | |
| // deployments without cosign are not broken. | |
| func (v *CosignVerifier) Verify(binaryPath, sigPath, certPath string) error { | |
| cosignBin, err := exec.LookPath("cosign") | |
| if err != nil { | |
| // By default, it will skip verification with a warning if the cosign CLI is | |
| // not installed, but this behavior can be configured via AllowMissingCosign. | |
| type CosignVerifier struct { | |
| OIDCIssuer string | |
| AllowedIdentityRegexp string | |
| // AllowMissingCosign controls behavior when the cosign CLI is not found in | |
| // PATH. If true, verification is skipped with a warning; if false, Verify | |
| // returns an error instead of silently succeeding. | |
| AllowMissingCosign bool | |
| } | |
| // NewCosignVerifier creates a CosignVerifier for the given OIDC issuer and | |
| // identity regexp (e.g. "https://github.com/GoCodeAlone/.*"). | |
| // | |
| // It is configured to allow missing cosign by default, preserving the | |
| // historical behavior of skipping verification when cosign is not installed. | |
| func NewCosignVerifier(oidcIssuer, identityRegexp string) *CosignVerifier { | |
| return &CosignVerifier{ | |
| OIDCIssuer: oidcIssuer, | |
| AllowedIdentityRegexp: identityRegexp, | |
| AllowMissingCosign: true, | |
| } | |
| } | |
| // NewStrictCosignVerifier creates a CosignVerifier that requires the cosign | |
| // CLI to be installed. If cosign is not found in PATH, Verify will return an | |
| // error instead of skipping verification. | |
| func NewStrictCosignVerifier(oidcIssuer, identityRegexp string) *CosignVerifier { | |
| return &CosignVerifier{ | |
| OIDCIssuer: oidcIssuer, | |
| AllowedIdentityRegexp: identityRegexp, | |
| AllowMissingCosign: false, | |
| } | |
| } | |
| // Verify runs `cosign verify-blob` to validate the signature of a plugin binary. | |
| // If cosign is not installed and AllowMissingCosign is true, a warning is | |
| // logged and nil is returned so that deployments without cosign are not | |
| // broken. If AllowMissingCosign is false, an error is returned instead. | |
| func (v *CosignVerifier) Verify(binaryPath, sigPath, certPath string) error { | |
| cosignBin, err := exec.LookPath("cosign") | |
| if err != nil { | |
| if !v.AllowMissingCosign { | |
| return fmt.Errorf("cosign binary not found in PATH: %w", err) | |
| } |
| // ValidatePlugin implements plugin.LicenseValidator. It delegates to the offline | ||
| // validator, which performs the authoritative check without network calls. | ||
| func (c *CompositeValidator) ValidatePlugin(pluginName string) error { | ||
| if c.offline != nil { | ||
| return c.offline.ValidatePlugin(pluginName) | ||
| } | ||
| return nil | ||
| } |
There was a problem hiding this comment.
CompositeValidator.ValidatePlugin returns nil when the offline validator is nil, which effectively approves every premium plugin in that configuration. Since plugin loading treats a nil error as licensed, this should fail closed (return an error) and/or explicitly delegate to the HTTP validator (e.g., via cached LicenseInfo) rather than silently allowing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
pkg/license/): Signedwflic.v1.<payload>.<signature>format tokens with offline verification — no HTTP call-home requiredlicensing/): Token-based offline license validation with automatic fallback to HTTP validatorplugins/license/): Adapter connectinglicensing.Validator↔plugin.LicenseValidatorinterfaces, embedded public key,WORKFLOW_LICENSE_TOKENenv var supportplugin/cosign.go,plugin/loader.go): Verifies premium plugin binary signatures via sigstore before loadingTest plan
go test ./pkg/license/...— 10 tests (round-trip, expiry, tampering, invalid formats)go test ./licensing/...— 19 tests (offline validator, composite fallback, HTTP compatibility)go test ./plugins/license/...— 11 tests (wiring hook, adapter, token scenarios)go build ./plugin/...— cosign verifier compiles cleanlygo test ./...— all 102 packages pass, zero failures🤖 Generated with Claude Code