feat: export the PluginPrimitiveSigner to enable signing with PKCS#7#572
feat: export the PluginPrimitiveSigner to enable signing with PKCS#7#572dallasd1 wants to merge 2 commits intonotaryproject:mainfrom
Conversation
Signed-off-by: Dallas Delaney <dadelan@microsoft.com>
…cFromPlugin Signed-off-by: Dallas Delaney <dadelan@microsoft.com>
| } | ||
|
|
||
| // GetKeySpecFromPlugin retrieves the key specification from a plugin by calling DescribeKey. | ||
| func GetKeySpecFromPlugin(ctx context.Context, p plugin.SignPlugin, keyID string, pluginConfig map[string]string) (signature.KeySpec, error) { |
There was a problem hiding this comment.
This is needed for the CLI PR that will pull the signing type to include in the PKCS#7 envelope
bketelsen
left a comment
There was a problem hiding this comment.
Some blockers - KeyID validation is dropped from new public method, no input validation for NewPluginPrimitiveSigner()
| } | ||
|
|
||
| // GetKeySpecFromPlugin retrieves the key specification from a plugin by calling DescribeKey. | ||
| func GetKeySpecFromPlugin(ctx context.Context, p plugin.SignPlugin, keyID string, pluginConfig map[string]string) (signature.KeySpec, error) { |
There was a problem hiding this comment.
the private getKeySpec validates the KeyID matching the requested keyID, but the public method doesn't. It should be validated like the existing private method does.
|
|
||
| resp, err := p.DescribeKey(ctx, req) | ||
| if err != nil { | ||
| return signature.KeySpec{}, err |
There was a problem hiding this comment.
wrap these errors in some context with fmt.Errorf("failed to describe key %q: %w", keyID, err)) to match the rest of plugin.go
| // NewPluginPrimitiveSigner creates a new PluginPrimitiveSigner that delegates | ||
| // signing to a plugin. This is used for dm-verity PKCS#7 signing where raw | ||
| // signature bytes are needed instead of JWS/COSE envelopes. | ||
| func NewPluginPrimitiveSigner(ctx context.Context, p plugin.SignPlugin, keyID string, keySpec signature.KeySpec, pluginConfig map[string]string) *PluginPrimitiveSigner { |
There was a problem hiding this comment.
NewPluginSigner validates inputs and returns an error but this version does neither. It would silently accept a nil plugin and/or empty keyID and store them, which would cause a panic later.
| } | ||
| basicVerification(t, data, envelopeType, mockPlugin.certs[len(mockPlugin.certs)-1], &validMetadata) | ||
| } | ||
|
|
There was a problem hiding this comment.
add test for nil plugin - see previous comment
| t.Fatalf("GetKeySpecFromPlugin() = %v, want %v", got, defaultKeySpec) | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Only happy path testing here. Need Sign() failure and at least one non-RSA key tested.
shizhMSFT
left a comment
There was a problem hiding this comment.
Caution
AI-generated review. This review was produced by an AI agent (Claude Opus 4.7, shizh-reviewer skill) on behalf of @shizhMSFT. It is posted as COMMENT (not REQUEST_CHANGES) — please weigh findings on their merits and treat them as starting points, not gating concerns.
Thanks @dallasd1. notation-go is more flexed than notation-core-go, but exporting an internal type into the public API of the most-imported library in the Notary Project ecosystem still deserves careful thought. A few concerns and one design question.
Design question first
Do we actually need to export
pluginPrimitiveSigner?
The stated reason is that the dm-verity flow lives in notaryproject/notation (the CLI) and needs a signature.Signer. Two alternatives that don't expand notation-go's public API:
- Move the dm-verity command into a
signer/dmveritysubpackage ofnotation-goand call the existingpluginPrimitiveSignerinternally. The CLI then composes via a public function likesigner.NewDMVeritySigner(plugin, keyID, opts)that returns[]bytedirectly. This keepspluginPrimitiveSignerprivate. - Add a single narrow public function
signer.SignPrimitive(ctx, plugin, keyID, payload, pluginConfig) ([]byte, []*x509.Certificate, error)that internally constructs and callspluginPrimitiveSigner. The caller never holds the type; we can refactor later without breaking compat.
Both reduce the API surface from "a struct + constructor + helper + KeySpec method" to one function. Per clig.dev reasoning applied to libraries: public API surface is a permanent contract; we should add the smallest amount that solves the use case. Right now this PR exports more than is strictly needed (see comments below). Worth at least answering "did we consider these?" before merge.
Concerns (if we proceed with the export)
GetKeySpecFromPlugindrops thekeyIDround-trip check. The existingPluginSigner.getKeySpec(signer/plugin.go:155–157) comparess.keyIDtodescKeyResp.KeyIDand rejects mismatches. The new helper does not. A malicious or buggy plugin can return a different key's spec; the caller will then sign with the wrong spec/algorithm. This is the kind of TOCTOU-adjacent issue that has bitten supply-chain tooling before.NewPluginPrimitiveSignerperforms no input validation.NewPluginSigner(signer/plugin.go:66–78) rejectsnilplugin and empty keyID. The new constructor accepts both and only fails later, deep insideSign(). It also accepts an arbitrarysignature.KeySpecvalue from the caller without sanity-checking it — the suggestion below adds local validation viaproto.HashAlgorithmFromKeySpec. Note: this is purely a "did you pass me a valid (Type, Size) pair?" check; it does not cross-validate the keySpec against what the plugin would report — for that, the caller must useKeySpecFromPluginfirst.*PluginPrimitiveSignerreturned from constructor. Constructors that return concrete pointers commit us to every exported field/method/struct-tag. Returnsignature.Signer(the interface the type already implements). Less to maintain; same usefulness.
Non-blocking design tension
context.Contextbaked into the public struct. The original code already does this internally, so exporting it doesn't introduce the anti-pattern — it propagates it.Sign(payload []byte)ignores its surroundings and usess.ctx, so timeouts/cancellations from the calling goroutine cannot reach the plugin RPC. Resolving this properly (either passingctxtoSign(ctx, payload)or hiding the ctx behind constructor scope) means changingsignature.Signer, which is out of scope for this PR. Worth flagging as a tracking issue for the next signing-API revision.
Naming
GetKeySpecFromPlugin→KeySpecFromPlugin(Go style: noGetprefix on getters; cf. https://go.dev/wiki/CodeReviewComments#getters)NewPluginPrimitiveSigneris fine, but considerNewSignaturePrimitiveSigneror just folding intoNewPluginSigneras a mode flag. "PluginPrimitive" reads as two adjectives stacked; users will get it wrong.
Verdict: COMMENT (AI review only — not gating).
| // pluginPrimitiveSigner implements signature.Signer | ||
| type pluginPrimitiveSigner struct { | ||
| // PluginPrimitiveSigner implements signature.Signer | ||
| type PluginPrimitiveSigner struct { |
There was a problem hiding this comment.
Now that this is
PluginPrimitiveSigner, every field is almost part of the public API by reflection /unsafeuse. Keep them unexported (they already are), but please also confirm thatsigner.PluginPrimitiveSigner{}(zero value) cannot be misused — e.g., add a runtime guard inSignandKeySpecthat returns an error ifs.plugin == nil. Otherwise external callers using&PluginPrimitiveSigner{}literals will hit nil-deref panics rather than clean errors.
| // NewPluginPrimitiveSigner creates a new PluginPrimitiveSigner that delegates | ||
| // signing to a plugin. This is used for dm-verity PKCS#7 signing where raw | ||
| // signature bytes are needed instead of JWS/COSE envelopes. | ||
| func NewPluginPrimitiveSigner(ctx context.Context, p plugin.SignPlugin, keyID string, keySpec signature.KeySpec, pluginConfig map[string]string) *PluginPrimitiveSigner { | ||
| return &PluginPrimitiveSigner{ | ||
| ctx: ctx, | ||
| plugin: p, | ||
| keyID: keyID, | ||
| keySpec: keySpec, | ||
| pluginConfig: pluginConfig, | ||
| } | ||
| } |
There was a problem hiding this comment.
| // NewPluginPrimitiveSigner creates a new PluginPrimitiveSigner that delegates | |
| // signing to a plugin. This is used for dm-verity PKCS#7 signing where raw | |
| // signature bytes are needed instead of JWS/COSE envelopes. | |
| func NewPluginPrimitiveSigner(ctx context.Context, p plugin.SignPlugin, keyID string, keySpec signature.KeySpec, pluginConfig map[string]string) *PluginPrimitiveSigner { | |
| return &PluginPrimitiveSigner{ | |
| ctx: ctx, | |
| plugin: p, | |
| keyID: keyID, | |
| keySpec: keySpec, | |
| pluginConfig: pluginConfig, | |
| } | |
| } | |
| // NewPluginPrimitiveSigner creates a new PluginPrimitiveSigner that delegates | |
| // signing to a plugin. This is intended for callers that need raw signature | |
| // bytes (e.g., dm-verity PKCS#7) rather than a JWS/COSE envelope. | |
| // | |
| // The keySpec must match what the plugin reports via DescribeKey. Use | |
| // KeySpecFromPlugin to obtain it, or construct via NewPluginSigner if you | |
| // just need standard envelope signing. | |
| func NewPluginPrimitiveSigner(ctx context.Context, p plugin.SignPlugin, keyID string, keySpec signature.KeySpec, pluginConfig map[string]string) (signature.Signer, error) { | |
| if p == nil { | |
| return nil, errors.New("nil plugin") | |
| } | |
| if keyID == "" { | |
| return nil, errors.New("keyID not specified") | |
| } | |
| if _, err := proto.HashAlgorithmFromKeySpec(keySpec); err != nil { | |
| return nil, fmt.Errorf("invalid keySpec: %w", err) | |
| } | |
| return &PluginPrimitiveSigner{ | |
| ctx: ctx, | |
| plugin: p, | |
| keyID: keyID, | |
| keySpec: keySpec, | |
| pluginConfig: pluginConfig, | |
| }, nil | |
| } |
Three changes: (1) validate inputs the same way NewPluginSigner does, (2) return signature.Signer (interface) rather than the concrete type so we can refactor later, (3) return an error so callers must handle setup failure. Same pattern as NewPluginSigner for consistency.
| // GetKeySpecFromPlugin retrieves the key specification from a plugin by calling DescribeKey. | ||
| func GetKeySpecFromPlugin(ctx context.Context, p plugin.SignPlugin, keyID string, pluginConfig map[string]string) (signature.KeySpec, error) { | ||
| req := &plugin.DescribeKeyRequest{ | ||
| ContractVersion: plugin.ContractVersion, | ||
| KeyID: keyID, | ||
| PluginConfig: pluginConfig, | ||
| } | ||
|
|
||
| resp, err := p.DescribeKey(ctx, req) | ||
| if err != nil { | ||
| return signature.KeySpec{}, err | ||
| } | ||
|
|
||
| return proto.DecodeKeySpec(resp.KeySpec) | ||
| } |
There was a problem hiding this comment.
| // GetKeySpecFromPlugin retrieves the key specification from a plugin by calling DescribeKey. | |
| func GetKeySpecFromPlugin(ctx context.Context, p plugin.SignPlugin, keyID string, pluginConfig map[string]string) (signature.KeySpec, error) { | |
| req := &plugin.DescribeKeyRequest{ | |
| ContractVersion: plugin.ContractVersion, | |
| KeyID: keyID, | |
| PluginConfig: pluginConfig, | |
| } | |
| resp, err := p.DescribeKey(ctx, req) | |
| if err != nil { | |
| return signature.KeySpec{}, err | |
| } | |
| return proto.DecodeKeySpec(resp.KeySpec) | |
| } | |
| // KeySpecFromPlugin retrieves and validates the key specification from a plugin | |
| // by calling DescribeKey. It enforces that the plugin's response references the | |
| // same keyID that was requested; this guards against a misbehaving plugin | |
| // returning a spec for a different key. | |
| func KeySpecFromPlugin(ctx context.Context, p plugin.SignPlugin, keyID string, pluginConfig map[string]string) (signature.KeySpec, error) { | |
| if p == nil { | |
| return signature.KeySpec{}, errors.New("nil plugin") | |
| } | |
| if keyID == "" { | |
| return signature.KeySpec{}, errors.New("keyID not specified") | |
| } | |
| resp, err := p.DescribeKey(ctx, &plugin.DescribeKeyRequest{ | |
| ContractVersion: plugin.ContractVersion, | |
| KeyID: keyID, | |
| PluginConfig: pluginConfig, | |
| }) | |
| if err != nil { | |
| return signature.KeySpec{}, err | |
| } | |
| if resp.KeyID != keyID { | |
| return signature.KeySpec{}, fmt.Errorf("keyID in describeKey response %q does not match request %q", resp.KeyID, keyID) | |
| } | |
| return proto.DecodeKeySpec(resp.KeySpec) | |
| } |
Two changes:
- Drop the
Getprefix per Go style. - Add the same
keyIDround-trip validation thatPluginSigner.getKeySpecperforms (lines 155–157). Without it, a plugin can respond with a different key's spec and we'd silently sign with the wrong algorithm. This is the same defense-in-depth checkPluginPrimitiveSigner.Sign()already does at lines 362–364 after signing — please mirror it before signing.
| func TestGetKeySpecFromPlugin_Error(t *testing.T) { | ||
| ctx := context.Background() | ||
| mp := &mockPlugin{} | ||
| _, err := GetKeySpecFromPlugin(ctx, mp, "testKeyID", nil) | ||
| if err == nil { |
There was a problem hiding this comment.
&mockPlugin{}returnsproto.DescribeKeyResponse{KeySpec: ks}whereks, _ := proto.EncodeKeySpec(p.keySpec)— for a zero-valuedkeySpecthis returns{}. The test passes today becauseproto.DecodeKeySpec("")errors, but the comment "expected error for empty keySpec" is misleading: we're really testing decode-side error handling, not the helper's input contract. Once the validation suggested above is added, please also add tests for: nil plugin, empty keyID, mismatched keyID round-trip, and a plugin whoseDescribeKeyitself errors. TheErrortest name should match the failure mode (TestKeySpecFromPlugin_KeyIDMismatch, etc.).
| func TestNewPluginPrimitiveSigner(t *testing.T) { | ||
| ctx := context.Background() | ||
| mp := newMockPlugin(defaultKeyCert.key, defaultKeyCert.certs, defaultKeySpec) | ||
|
|
||
| s := NewPluginPrimitiveSigner(ctx, mp, "testKeyID", defaultKeySpec, nil) | ||
|
|
||
| // verify KeySpec | ||
| ks, err := s.KeySpec() | ||
| if err != nil { | ||
| t.Fatalf("KeySpec() error: %v", err) | ||
| } | ||
| if ks != defaultKeySpec { | ||
| t.Fatalf("KeySpec() = %v, want %v", ks, defaultKeySpec) | ||
| } | ||
|
|
There was a problem hiding this comment.
Once
NewPluginPrimitiveSignerreturns(signature.Signer, error)per the suggestion above, please also assert it returns an error for(nil, "key", spec, nil)and(plugin, "", spec, nil). MirroringNewPluginSigner's test coverage.
| } | ||
|
|
||
| primitivePluginSigner := &pluginPrimitiveSigner{ | ||
| primitivePluginSigner := &PluginPrimitiveSigner{ |
There was a problem hiding this comment.
nit: the local variable was
primitivePluginSignerand is now&PluginPrimitiveSigner{...}— fine, but consider renaming the variable topluginPrimitiveSigner(lowercase first letter for a local var) so the only thing that changed is the type, not the variable's spelling. Helps git blame.
Type PluginPrimitiveSigner is needed for generating PKCS#7 signatures by code living in the notation CLI repo. That new (CLI) logic bypasses the PluginSigner wrapper that JWS and COSE use. This change exports the type to make it available outside of the notation-go repo and additionally adds helper functions for external callers.
The default behavior in the signing path for JWS/COSE when calling generateSignature remains unchanged.