Skip to content

feat: added PKCS#7 envelope implementation #298

Open
dallasd1 wants to merge 3 commits into
notaryproject:mainfrom
dallasd1:dadelan/pkcs7-envelope
Open

feat: added PKCS#7 envelope implementation #298
dallasd1 wants to merge 3 commits into
notaryproject:mainfrom
dallasd1:dadelan/pkcs7-envelope

Conversation

@dallasd1
Copy link
Copy Markdown

@dallasd1 dallasd1 commented Mar 12, 2026

Add the PKCS#7 envelope implementation to support creating signatures for dm-verity. The kernel's dm-verity feature requires PKCS#7 signatures without authenticated attributes.

This PR leverages the open source mozilla pkcs7 package.

#298

Adds PKCS#7/CMS signature envelope implementation following the same
pattern as existing JWS and COSE envelopes.

Features:
- Implements signature.Envelope interface (Sign, Verify, Content)
- Uses go.mozilla.org/pkcs7 library for ASN.1 encoding
- Uses signerAdapter pattern to support both local and remote signers
- Works with Azure Key Vault and other plugins via signature.Signer
- Produces detached signatures for dm-verity kernel verification
- Supports RSA and ECDSA key types with SHA-256
- Registers media type application/pkcs7-signature

The signerAdapter wraps pre-computed signatures from any signature.Signer
to satisfy the crypto.Signer interface expected by the Mozilla library.
This enables remote signers (like Azure Key Vault) that don't expose
private keys to work with the library.

Signed-off-by: Dallas Delaney <dadelan@microsoft.com>
The base.Envelope wrapper validates that signing-time is present, but
PKCS#7 signatures for dm-verity must not include authenticated attributes
(including signing-time) per Linux kernel requirements. The kernel's
PKCS#7 verifier in crypto/asymmetric_keys/public_key.c expects raw
signature data without CMS authenticated attributes.

Remove the base.Envelope wrapper from NewEnvelope() and ParseEnvelope()
so the pkcs7 envelope implements signature.Envelope directly.

Signed-off-by: Dallas Delaney <dadelan@microsoft.com>
Signed-off-by: Dallas Delaney <dadelan@microsoft.com>
@github-actions
Copy link
Copy Markdown

This PR is stale because it has been opened for 45 days with no activity. Remove stale label or comment. Otherwise, it will be closed in 30 days.

@github-actions github-actions Bot added the Stale label Apr 27, 2026
@yizha1 yizha1 removed the Stale label Apr 27, 2026
Copy link
Copy Markdown

@bketelsen bketelsen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR looks good but there are a few blockers, critically Verify() doesn't actually do cryptographic validation and SHA256 is hardcoded for all key types.

return nil, &signature.SignatureEnvelopeNotFoundError{}
}
// For detached signatures, the kernel does dm-verity verification
return e.Content()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method calls Content() which only parses structure... no cert chain validation or expiration check. cose and jws implementations each validate, but this doesn't. should call e.p7.Verify()

}

// Set digest algorithm to SHA-256 (kernel dm-verity requirement)
sd.SetDigestAlgorithm(gopkcs7.OIDDigestAlgorithmSHA256)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

notary spec has algo/hash pairings:

  • RSA-3072 → SHA-384; RSA-4096 → SHA-512
  • ECDSA P-384 → SHA-384; ECDSA P-521 → SHA-512

but this hardcodes SHA-256 for all. Tests only cover RSA-2048 so this bug is never exposed.

}

// Parse the result to populate envelope fields
p7, _ := gopkcs7.Parse(encoded)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't swallow an error on parsing... if caller sees success, subsequent calls to Verify() or Content() will be incorrect.

// Note: Unlike JWS/COSE, PKCS#7 for dm-verity does NOT use base.Envelope wrapper
// because dm-verity signatures must not have signing-time (kernel requirement).
func NewEnvelope() signature.Envelope {
return &envelope{}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jws and cose return &base.Envelope{Envelope: &envelope{}} which has critical validations. returning a bare envelope bypasses all of these checks.


const testPayload = "test dm-verity root hash payload"

// newRSATestSigner creates an RSA-2048 test signer using testhelper certs.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need tests to validate other chains still work like RSA-3072 and ECDSA p-384

// See the License for the specific language governing permissions and
// limitations under the License.

package pkcs7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test suite is missing many of the patterns present in jws and cose - fuzzing, negative tests, ECDSA, conformance. It is not complete without them.

"github.com/notaryproject/notation-core-go/signature"
gopkcs7 "go.mozilla.org/pkcs7"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jws and cose have compile-time assertions... this doesn't. var _ signature.Envelope = &envelope{}

Copy link
Copy Markdown
Contributor

@shizhMSFT shizhMSFT left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Hybrid review. The dependency concern (Finding 1 on go.mozilla.org/pkcs7) reflects @shizhMSFT's own position and is the basis for this REQUEST_CHANGES. Everything else in this review was drafted by an AI agent (Claude Opus 4.7, shizh-reviewer skill) and is offered for the author's consideration only — please weigh those points on their merits, not as gating concerns.

Thanks for kicking this off, @dallasd1. notation-core-go is the cryptographic core SDK for the entire Notary Project ecosystem, so the bar on new direct dependencies is high.

Findings

Finding 1 below is the gating concern from @shizhMSFT. Findings 2–6 are AI-drafted observations to consider on their merits.

  1. Do we need go.mozilla.org/pkcs7 at all? tspclient-go already gives us most of the CMS surface in-house. notaryproject/tspclient-go ships internal/cms, internal/oid, internal/encoding/asn1/ber, and internal/hashutil — a pure-stdlib RFC 5652 implementation that has been in production for RFC 3161 timestamping. And notation-core-go/go.mod already directly requires github.com/notaryproject/tspclient-go v1.0.0 (line 8 of this PR's go.mod), so reusing that code costs zero new modules; importing go.mozilla.org/pkcs7 adds one. Concrete contrast points:

    • Correct OIDs. tspclient-go/internal/oid/oid.go defines ECDSAWithSHA256 = {1,2,840,10045,4,3,2} per RFC 5758 §3.2 (a signature-algorithm OID). This PR inherits gopkcs7.OIDEncryptionAlgorithmECDSAP256 = {1,2,840,10045,3,1,7} which is the secp256r1 curve OID — putting it in SignerInfo.signatureAlgorithm violates RFC 5652 §10.1.2 and produces a non-conformant CMS structure (Finding 5).
    • Real Verify(). tspclient-go/internal/cms/signed.go::verifySignature performs cert-chain validation, signature verification via cert.CheckSignature, and signed-attribute processing. This PR's Verify() performs no cryptographic check at all (Finding 2).
    • No dead weight. go.mozilla.org/pkcs7 ships decrypt.go, encrypt.go, a BER parser, and DSA support — none of which dm-verity needs.

    Concrete proposal — three options, in increasing order of permanence:

    • (a) Copy + refactor later. Since both notation-core-go and tspclient-go live under notaryproject, copy tspclient-go/internal/cms (+ oid, ber, hashutil) into notation-core-go/internal/cms as a temporary measure, with a // TODO linking to a tracking issue. Pros: zero coordination cost, lands in this PR. Cons: code duplication until we deduplicate; both copies will drift unless we explicitly track them.
    • (b) Lift tspclient-go/internal/cms to a non-internal package. E.g. github.com/notaryproject/tspclient-go/cms. notation-core-go then imports it directly (it already requires tspclient-go v1.0.0). Pros: single source of truth; no new module. Cons: requires a tspclient-go release with new public API surface, and any future change to cms is now subject to the tspclient-go ABI contract.
    • (c) New shared module notaryproject/cms-go. Extract the CMS code into a dedicated repo + module that both tspclient-go and notation-core-go consume. Pros: cleanest separation; mirrors how tspclient-go itself was factored out. Cons: new repo, new release cadence, more moving parts.

    One caveat for any of these options: today tspclient-go/internal/cms only implements parse + verify. It does not implement sign. So this is not a literal drop-in replacement for gopkcs7's signing path; it's "reuse the structs + BER + correct OIDs + verify, then add ~150 lines of SignedData.Marshal + SignerInfo.Marshal." Still a substantially smaller delta than importing go.mozilla.org/pkcs7, with correct OIDs from day one and a verifier that actually verifies.

    /cc @priteshbandi @yizha1 — happy to file a tracking issue covering whichever path the maintainers prefer.

  2. Verify() doesn't verify, and structurally cannot in this shape. envelope.go:188-194 just returns Content(). No cryptographic check. Worse, even fixing it is non-trivial: detached-PKCS#7 verification needs the original content, but signature.Envelope.Verify() takes no argument — after ParseEnvelope the envelope only holds the DER bytes. So unless we change the interface or stash the content in the envelope itself (which JWS/COSE don't have to do because they're attached), a freshly-parsed PKCS#7 envelope can never have a working Verify() through this interface. This is the strongest signal that PKCS#7 doesn't fit the signature.Envelope abstraction (see design question). For now, registering a verifier that returns "verified" without verifying is worse than not registering it at all.

  3. Hardcoded SHA-256 + signature-passthrough adapter is cryptographically broken for any key that isn't RSA-2048. Walk through with RSA-3072: proto.HashAlgorithmFromKeySpec returns SHA-384, AKV's SignDataAsync(RS384, payload) server-side hashes with SHA-384 and returns RSA(SHA-384(payload)), this PR wraps that signature in a PKCS#7 SignerInfo whose digestAlgorithm is hardcoded to id-sha256. Any verifier (kernel dm-verity or compliant CMS verifier) computes SHA-256(content) and the signature does not match. Same for RSA-4096 → SHA-512, EC P-384 → SHA-384, EC P-521 → SHA-512. The signerAdapter.Sign() ignoring the digest parameter (a violation of crypto.Signer's contract) is what hides the mismatch.

  4. SignatureAlgorithm is mislabeled in the parsed envelope. extractAlgorithm (line 220-228) calls keySpec.SignatureAlgorithm(), which for any KeyTypeRSA returns AlgorithmPS{256,384,512} (internal/algorithm/algorithm.go:83-90). But this PR produces RSASSA-PKCS#1 v1.5 signatures, not PSS. So Content().SignerInfo.SignatureAlgorithm will say "RSASSA-PSS" for a signature that is actually PKCS#1 v1.5 — a silent type-system lie. Fixing it requires adding RSASSA-PKCS1-v1_5-SHA-{256,384,512} to signature.Algorithm (the enum currently has only PS/ES — signature/algorithm.go:29-34), which in turn requires a coordinated change to notaryproject/specifications (signature-specification.md algorithm table) and notation-go plugin proto validation. This is a cross-PR cascade — the feature stack does not work end-to-end without all three repos moving together. Worth filing a tracking issue.

  5. EC OIDs are wrong; should we just reject EC at the envelope layer? Tied to Finding 1: getEncryptionOID returns OIDEncryptionAlgorithmECDSAP256/384/521 from go.mozilla.org/pkcs7, which are named-curve OIDs (prime256v1/secp384r1/secp521r1), not signature-algorithm OIDs. RFC 5652 §10.1.2 requires signatureAlgorithm in SignerInfo to identify the signature algorithm (e.g., ecdsa-with-SHA256 1.2.840.10045.4.3.2); putting a curve OID there fails any conformant CMS verifier. Given that gopkcs7 ships these incorrectly and the kernel dm-verity flow does not consume ECDSA anyway, should we just reject EC keys explicitly at the envelope boundary and remove the EC branches?

  6. Test coverage misses the critical path. Tests use a local crypto.Signer. The PR's stated motivation for signerAdapter is "to support remote signers (Azure Key Vault, plugins)" — but no test exercises that path. Please add a test that signs via a fake remote signer with a non-SHA-256 hash and asserts the resulting PKCS#7 either (a) fails to produce a SHA-256 envelope or (b) verifies against SHA256(content) end-to-end with the leaf cert. The bug in Finding 3 will surface immediately.

Design question

Per the issue thread, dm-verity needs detached PKCS#7 with no signed attributes, hardcoded SHA-256, and RSA-PKCS#1 v1.5. That's a very narrow contract — barely a "signature envelope" in the JWS/COSE sense (no expiry, no signing-time, no envelope verify). And as Finding 2 shows, Verify() is structurally broken in this shape. Should this instead be a small pkcs7 helper package that exposes Sign(payload, signer) ([]byte, error) directly, without registering through signature.Envelope? Once we register MediaTypeEnvelope = "application/pkcs7-signature" in a release, removing or renaming it is a breaking change every future notation-core-go release must honor — for a single-consumer envelope. Are we OK with that ABI commitment?

Verdict: REQUEST_CHANGES from @shizhMSFT, gating specifically on Finding 1 (the go.mozilla.org/pkcs7 dependency in notation-core-go). Findings 2–6, the design question, the non-blocking suggestions, and all inline comments below are AI-drafted and offered for the author's consideration — not gating. Happy to discuss in the next community meeting.

Non-blocking suggestions

  • init() registration. Once signature.RegisterEnvelopeType("application/pkcs7-signature", ...) runs at import time, every downstream that imports notation-core-go/signature (transitively, through any verifier) pulls in go.mozilla.org/pkcs7. JWS/COSE do the same today, so this is an established pattern, not a new contract — but PKCS#7 has exactly one consumer (dm-verity). Should it be opt-in registration via blank import (_ "github.com/notaryproject/notation-core-go/signature/pkcs7"), the way database/sql drivers do?
  • go.mod hygiene. Line 15 lists go.mozilla.org/pkcs7 v0.9.0 // indirect, but signature/pkcs7/envelope.go:27 imports it directly. go mod tidy should move it to the direct require block.

Comment on lines +33 to +37
func init() {
if err := signature.RegisterEnvelopeType(MediaTypeEnvelope, NewEnvelope, ParseEnvelope); err != nil {
panic(err)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See top-level "non-blocking suggestions". JWS/COSE register the same way, but PKCS#7 has a single consumer (dm-verity). Worth considering blank-import opt-in (database/sql-driver pattern).

Comment on lines +49 to +51
func NewEnvelope() signature.Envelope {
return &envelope{}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewEnvelope() returns a zero-value *envelope, then Sign() mutates e.raw/p7/certs/sigBytes (lines 134–138) so subsequent Verify/Content calls return what was just signed. This conflates construction with signing-result. The JWS/COSE envelopes use base.Envelope precisely to separate these. The PR comment acknowledges skipping base.Envelope "because dm-verity signatures must not have signing-time" — but base.Envelope doesn't force signing-time on you; only the envelope-specific Sign method does. Please reconsider; sharing base.Envelope (with the signing-time bypass) gives consistent state semantics and free Payload() accessors.

Comment on lines +73 to +74
// Sign implements signature.Envelope interface.
// Uses signerAdapter pattern to support both local and remote signers (AKV, plugins).
Copy link
Copy Markdown
Contributor

@shizhMSFT shizhMSFT May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Sign implements signature.Envelope interface.
// Uses signerAdapter pattern to support both local and remote signers (AKV, plugins).
// Sign implements signature.Envelope interface.
//
// Constraints (kernel dm-verity, see kernel docs/admin-guide/device-mapper/verity.rst):
// - RSASSA-PKCS#1 v1.5 only (no PSS, no ECDSA)
// - SHA-256 only
// - No signed attributes (so the signature is over SHA-256(content) directly)
//
// The implementation MUST therefore reject any KeySpec for which the upstream
// signer would not produce a SHA-256 RSASSA-PKCS#1 v1.5 signature (today,
// only RSA-2048 — see proto.HashAlgorithmFromKeySpec), or it will declare
// digestAlgorithm = id-sha256 in the envelope while the actual signature
// covers a SHA-384/SHA-512 digest.

The current code declares SHA-256 in the envelope but accepts any signature the upstream signer produces. See top-level Finding 3 for the full chain. Two ways to fix the body: (a) restrict req.Signer keyspecs to RSA-2048 and reject anything else with UnsupportedSigningKeyError, or (b) introduce a signature.Signer extension that lets us request an explicit hash algorithm before signing.

}

// Set digest algorithm to SHA-256 (kernel dm-verity requirement)
sd.SetDigestAlgorithm(gopkcs7.OIDDigestAlgorithmSHA256)
Copy link
Copy Markdown
Contributor

@shizhMSFT shizhMSFT May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See top-level Finding 3.

sd.SetEncryptionAlgorithm(encryptionOID)

// Sign without authenticated attributes (kernel dm-verity requirement)
if err := sd.SignWithoutAttr(certs[0], adapter, gopkcs7.SignerInfoConfig{}); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the upstream comment: "This function is needed to sign old Android APKs, something you probably shouldn't do unless you're maintaining backward compatibility for old applications." Please add a comment explaining why we use it here (kernel dm-verity requires no SignedAttributes) so future readers don't think we hit this by accident.

Comment on lines +132 to +139
// Parse the result to populate envelope fields
p7, _ := gopkcs7.Parse(encoded)
e.raw = encoded
e.p7 = p7
e.certs = certs
if p7 != nil && len(p7.Signers) > 0 {
e.sigBytes = p7.Signers[0].EncryptedDigest
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Parse the result to populate envelope fields
p7, _ := gopkcs7.Parse(encoded)
e.raw = encoded
e.p7 = p7
e.certs = certs
if p7 != nil && len(p7.Signers) > 0 {
e.sigBytes = p7.Signers[0].EncryptedDigest
}
p7, err := gopkcs7.Parse(encoded)
if err != nil {
return nil, &signature.InvalidSignatureError{Msg: fmt.Sprintf("self-parse failed after Sign: %v", err)}
}
e.raw = encoded
e.p7 = p7
e.certs = certs
if len(p7.Signers) > 0 {
e.sigBytes = p7.Signers[0].EncryptedDigest
}

"Always check errors before processing any return values." We just produced the bytes ourselves; if Parse fails on our own output, something is very wrong and we should fail fast.

Comment on lines +220 to +228
func extractAlgorithm(certs []*x509.Certificate) (signature.Algorithm, error) {
if len(certs) == 0 {
return 0, fmt.Errorf("no certificates available to determine algorithm")
}
keySpec, err := signature.ExtractKeySpec(certs[0])
if err != nil {
return 0, err
}
return keySpec.SignatureAlgorithm(), nil
Copy link
Copy Markdown
Contributor

@shizhMSFT shizhMSFT May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See top-level Finding 4. keySpec.SignatureAlgorithm() for KeyTypeRSA returns AlgorithmPS{256,384,512} (internal/algorithm/algorithm.go:83-90) — RSASSA-PSS, not PKCS#1 v1.5. So the parsed EnvelopeContent.SignerInfo.SignatureAlgorithm reports the wrong algorithm. To fix this honestly we need a new signature.Algorithm enum member (RSASSA-PKCS1-v1_5-SHA-{256,384,512}), which in turn requires coordinated changes in notaryproject/specifications and notation-go plugin proto validation. Please file a tracking issue covering all three repos.

Comment on lines +32 to +40
func newRSATestSigner() *testPrimitiveSigner {
tuple := testhelper.GetRSACertTuple(2048)
rootCert := testhelper.GetRSARootCertificate().Cert
return &testPrimitiveSigner{
key: tuple.PrivateKey,
certs: []*x509.Certificate{tuple.Cert, rootCert},
keySpec: signature.KeySpec{Type: signature.KeyTypeRSA, Size: 2048},
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the keyspec restriction lands per the comment above, please add explicit negative tests: RSA-3072, RSA-4096, EC P-256/P-384/P-521 should all return UnsupportedSigningKeyError. Right now the test signs with RSA-2048 and never exercises the buggy paths.

Comment on lines +124 to +138
// TestSignError tests that Sign fails with a broken signer.
func TestSignError(t *testing.T) {
env := NewEnvelope()
req := &signature.SignRequest{
Payload: signature.Payload{
ContentType: MediaTypeEnvelope,
Content: []byte(testPayload),
},
Signer: &failingSigner{},
}
_, err := env.Sign(req)
if err == nil {
t.Fatal("Sign() with failing signer expected error, got nil")
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole stated motivation for signerAdapter is supporting remote signers. Please add a test that:

  1. Uses a fake signature.Signer whose Sign(content) returns a precomputed RSA-PKCS#1 v1.5(SHA-256(content)) from a known cert chain.
  2. Signs via the envelope.
  3. Parses the result and independently recomputes SHA256(content) and verifies the signature with rsa.VerifyPKCS1v15.

This is the contract dm-verity needs end-to-end. If it doesn't pass with RSA-3072 today, the bug surfaces immediately.

Comment thread go.mod
require github.com/x448/float16 v0.8.4 // indirect
require (
github.com/x448/float16 v0.8.4 // indirect
go.mozilla.org/pkcs7 v0.9.0 // indirect
Copy link
Copy Markdown
Contributor

@shizhMSFT shizhMSFT May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See top-level Finding 1. notation-core-go/go.mod already requires github.com/notaryproject/tspclient-go v1.0.0 directly, whose (currently internal) cms + oid packages already implement RFC 5652 SignedData parse + verify on stdlib with correct OIDs. Three options to reuse that work without adding go.mozilla.org/pkcs7: (a) copy the code into notation-core-go/internal/cms as a temporary measure (same-org duplication, refactor later), (b) lift tspclient-go/internal/cms to a non-internal package and import directly, or (c) extract to a new shared notaryproject/cms-go module. Worth raising with @priteshbandi / @rgnote before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants