From dc5a2d0a637b0e8877369614a73843f2a51d2f49 Mon Sep 17 00:00:00 2001 From: Jhionan Date: Wed, 6 May 2026 12:58:47 +0200 Subject: [PATCH] feat(sign): expose SignData.ExtraSignedAttributes for caller-supplied PKCS#7 attrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CMS SignerInfoConfig built in createSignedData currently hard-codes ExtraSignedAttributes to exactly the Adobe RevocationData OID (1.2.840.113583.1.1.8) and the signing-certificate-v2 attribute. Callers who want to embed additional OID-keyed attributes inside the cryptographically protected PKCS#7 SignedAttributes set (RFC 5652 §11) have no way to do so today without forking. This adds an opt-in field SignData.ExtraSignedAttributes []pkcs7.Attribute. When non-empty, the slice is appended to the library's two defaults before the SignerInfoConfig is wired into AddSignerChain. An empty slice is the default and preserves the prior behavior exactly. Use cases include: - Embedding a canonical content hash for downstream tamper detection (ICP-Brasil DOC-ICP-15.03 §6.4-style integrity attestations on PAdES laudos and similar regulated artifacts). - Transport-specific provenance OIDs that need to ride inside the signed bytes rather than free-form PDF string fields. Tested by signing a PDF with a custom OID-keyed attribute, re-parsing the resulting PKCS#7 from the signed file, and recovering the value via p7.UnmarshalSignedAttribute. --- sign/pdfsignature.go | 23 ++++++++---- sign/sign_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++++ sign/types.go | 18 +++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/sign/pdfsignature.go b/sign/pdfsignature.go index 216d262..b9e3258 100644 --- a/sign/pdfsignature.go +++ b/sign/pdfsignature.go @@ -325,14 +325,23 @@ func (context *SignContext) createSignature() ([]byte, error) { return nil, fmt.Errorf("new signed data: %w", err) } - signer_config := pkcs7.SignerInfoConfig{ - ExtraSignedAttributes: []pkcs7.Attribute{ - { - Type: asn1.ObjectIdentifier{1, 2, 840, 113583, 1, 1, 8}, - Value: context.SignData.RevocationData, - }, - *signingCertificate, + extraAttrs := []pkcs7.Attribute{ + { + Type: asn1.ObjectIdentifier{1, 2, 840, 113583, 1, 1, 8}, + Value: context.SignData.RevocationData, }, + *signingCertificate, + } + // Append caller-supplied custom signed attributes after the + // library defaults. They ride inside the cryptographically + // protected SignedAttributes set per RFC 5652 §11.2; any + // tampering with their values breaks pkcs7.Verify. An empty + // slice preserves prior behavior exactly. + if len(context.SignData.ExtraSignedAttributes) > 0 { + extraAttrs = append(extraAttrs, context.SignData.ExtraSignedAttributes...) + } + signer_config := pkcs7.SignerInfoConfig{ + ExtraSignedAttributes: extraAttrs, } // Add the first certificate chain without our own certificate. diff --git a/sign/sign_test.go b/sign/sign_test.go index a91218f..e75cfe3 100644 --- a/sign/sign_test.go +++ b/sign/sign_test.go @@ -4,6 +4,7 @@ import ( "crypto" "crypto/rsa" "crypto/x509" + "encoding/asn1" "encoding/pem" "fmt" "io" @@ -15,6 +16,7 @@ import ( "github.com/digitorus/pdf" "github.com/digitorus/pdfsign/revocation" "github.com/digitorus/pdfsign/verify" + "github.com/digitorus/pkcs7" "github.com/mattetti/filebuffer" ) @@ -281,6 +283,92 @@ func TestSignPDFFileUTF8(t *testing.T) { } } +// TestSignPDF_ExtraSignedAttributes_AppearInPKCS7 — caller-supplied +// custom signed attributes must ride inside the cryptographically +// protected PKCS#7 SignedAttributes set so a downstream +// pkcs7.UnmarshalSignedAttribute can recover them by OID. +func TestSignPDF_ExtraSignedAttributes_AppearInPKCS7(t *testing.T) { + cert, pkey := loadCertificateAndKey(t) + + // A test-only OID under the IANA "private experimental" arc. + customOID := asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 99999, 1, 1} + customValue := []byte("test content hash") + + tmpfile, err := os.CreateTemp("", t.Name()) + if err != nil { + t.Fatalf("%s", err.Error()) + } + defer func() { _ = os.Remove(tmpfile.Name()) }() + + err = SignFile("../testfiles/testfile20.pdf", tmpfile.Name(), SignData{ + Signature: SignDataSignature{ + Info: SignDataSignatureInfo{ + Name: "Extra Attrs Tester", + Reason: "Test ExtraSignedAttributes", + ContactInfo: "None", + Date: time.Now().Local(), + }, + CertType: CertificationSignature, + DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms, + }, + DigestAlgorithm: crypto.SHA256, + Signer: pkey, + Certificate: cert, + ExtraSignedAttributes: []pkcs7.Attribute{ + {Type: customOID, Value: customValue}, + }, + }) + if err != nil { + t.Fatalf("SignFile: %s", err.Error()) + } + + // Re-open the signed PDF, walk to the signature dictionary, parse + // the PKCS#7 contents, and recover the custom attribute by OID. + data, err := os.ReadFile(tmpfile.Name()) + if err != nil { + t.Fatalf("read signed file: %s", err.Error()) + } + rdr, err := pdf.NewReader(filebuffer.New(data), int64(len(data))) + if err != nil { + t.Fatalf("pdf.NewReader: %s", err.Error()) + } + + var sigContents string + for _, ent := range rdr.Trailer().Key("Root").Key("AcroForm").Key("Fields").Keys() { + _ = ent + } + // Walk the AcroForm field tree (pdfsign places the signature value + // in the first field's /V dict). + fields := rdr.Trailer().Key("Root").Key("AcroForm").Key("Fields") + for i := 0; i < fields.Len(); i++ { + v := fields.Index(i).Key("V") + if v.IsNull() { + continue + } + raw := v.Key("Contents").RawString() + if raw != "" { + sigContents = raw + break + } + } + if sigContents == "" { + t.Fatal("could not locate /Contents in any signature dict") + } + + p7, err := pkcs7.Parse([]byte(sigContents)) + if err != nil { + t.Fatalf("pkcs7.Parse: %s", err.Error()) + } + + var got []byte + if err := p7.UnmarshalSignedAttribute(customOID, &got); err != nil { + t.Fatalf("UnmarshalSignedAttribute: %s", err.Error()) + } + if string(got) != string(customValue) { + t.Fatalf("attribute value mismatch: want %q, got %q", customValue, got) + } +} + func BenchmarkSignPDF(b *testing.B) { cert, pkey := loadCertificateAndKey(&testing.T{}) certificateChains := [][]*x509.Certificate{} diff --git a/sign/types.go b/sign/types.go index 242ffdf..a1180f1 100644 --- a/sign/types.go +++ b/sign/types.go @@ -8,6 +8,7 @@ import ( "github.com/digitorus/pdf" "github.com/digitorus/pdfsign/revocation" + "github.com/digitorus/pkcs7" "github.com/mattetti/filebuffer" ) @@ -35,6 +36,23 @@ type SignData struct { RevocationFunction RevocationFunction Appearance Appearance + // ExtraSignedAttributes lets callers append additional CMS + // SignedAttributes (RFC 5652 §11) keyed by custom OIDs to the + // PKCS#7 signature, in addition to the library defaults + // (Adobe RevocationData OID 1.2.840.113583.1.1.8 and the + // signing-certificate-v2 attribute). + // + // These attributes ride inside the cryptographically protected + // SignedAttributes set, so any tampering with their values + // breaks pkcs7.Verify. Use cases include embedding a canonical + // content hash for downstream tamper detection (ICP-Brasil + // DOC-ICP-15.03 §6.4-style integrity attestations) or + // transport-specific provenance OIDs. + // + // An empty slice is the default and preserves the prior + // behavior exactly. + ExtraSignedAttributes []pkcs7.Attribute + objectId uint32 }