diff --git a/README.md b/README.md index 71e702d..2329c59 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf) [![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml) -![coverage](https://img.shields.io/badge/coverage-85.0%25-green) +![coverage](https://img.shields.io/badge/coverage-86.8%25-green) [![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf) [![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/) [![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/) diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 91ec9d3..dca7ff8 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.0" +const Version = "1.0.1" diff --git a/signature/byterange.go b/signature/byterange.go index 0c83ded..6773b3c 100644 --- a/signature/byterange.go +++ b/signature/byterange.go @@ -10,8 +10,11 @@ import ( const ( // signatureMaxLength is the maximum size of the hex-encoded CMS signature. - // 8192 bytes = 4096 bytes of raw signature data, sufficient for RSA-4096 + cert chain. + // 8192 hex chars = 4096 bytes of raw signature data, sufficient for RSA-4096 + cert chain. signatureMaxLength = 8192 + // signatureMaxLengthTSA is the maximum size when RFC 3161 timestamping is enabled. + // 20480 hex chars = 10240 bytes, accommodates signature + TSA certificate + timestamp token. + signatureMaxLengthTSA = 20480 ) // buildSignedPDF prepares a PDF with signature placeholder. @@ -70,10 +73,14 @@ func buildSignedPDF(pdfData []byte, signer Signer, cfg *signConfig) (*signResult appendBuf.WriteByte('\n') // Contents placeholder (hex string with zeros) + maxLen := signatureMaxLength + if cfg.tsaURL != "" { + maxLen = signatureMaxLengthTSA + } contentsPrefix := "/Contents <" contentsOffset := len(data) + appendBuf.Len() + len(contentsPrefix) appendBuf.WriteString(contentsPrefix) - placeholder := strings.Repeat("0", signatureMaxLength) + placeholder := strings.Repeat("0", maxLen) appendBuf.WriteString(placeholder) appendBuf.WriteString(">\n") diff --git a/signature/cms.go b/signature/cms.go index 9868979..37209ac 100644 --- a/signature/cms.go +++ b/signature/cms.go @@ -51,6 +51,7 @@ type signerInfo struct { SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` SignatureAlgorithm pkix.AlgorithmIdentifier Signature []byte + UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"` } type issuerAndSerialNumber struct { @@ -88,7 +89,8 @@ func computeSignature(key crypto.PrivateKey, digest []byte) ([]byte, error) { } // buildSignerInfoBytes builds and marshals the SignerInfo structure. -func buildSignerInfoBytes(cert *x509.Certificate, attrsBytes []byte, sig []byte, digestAlg, sigAlg pkix.AlgorithmIdentifier) ([]byte, error) { +// If unsignedAttrsBytes is non-nil, it is included as unsigned attributes (e.g., timestamp token). +func buildSignerInfoBytes(cert *x509.Certificate, attrsBytes []byte, sig []byte, digestAlg, sigAlg pkix.AlgorithmIdentifier, unsignedAttrsBytes []byte) ([]byte, error) { issuerRaw := asn1.RawValue{FullBytes: cert.RawIssuer} innerAttrsBytes, err := extractInnerBytes(attrsBytes) @@ -119,6 +121,10 @@ func buildSignerInfoBytes(cert *x509.Certificate, attrsBytes []byte, sig []byte, Signature: sig, } + if len(unsignedAttrsBytes) > 0 { + si.UnsignedAttrs = asn1.RawValue{FullBytes: unsignedAttrsBytes} + } + return asn1.Marshal(si) } @@ -162,14 +168,42 @@ func createCMSSignature(hash []byte, signer Signer, cfg *signConfig) ([]byte, er return nil, fmt.Errorf("sign: %w", err) } - siBytes, err := buildSignerInfoBytes(cert, attrsBytes, sig, digestAlg, sigAlg) + // Fetch RFC 3161 timestamp token if TSA URL is configured + unsignedAttrsBytes, err := buildTimestampAttrs(cfg.tsaURL, sig) + if err != nil { + return nil, err + } + + siBytes, err := buildSignerInfoBytes(cert, attrsBytes, sig, digestAlg, sigAlg, unsignedAttrsBytes) if err != nil { return nil, fmt.Errorf("marshal signer info: %w", err) } + return marshalSignedData(cert, signer.Chain, siBytes, digestAlg) +} + +// buildTimestampAttrs fetches a timestamp token and builds unsigned attributes. +// Returns nil if tsaURL is empty. +func buildTimestampAttrs(tsaURL string, sig []byte) ([]byte, error) { + if tsaURL == "" { + return nil, nil + } + token, err := fetchTimestamp(tsaURL, sig) + if err != nil { + return nil, fmt.Errorf("timestamp: %w", err) + } + unsignedAttrs, err := buildUnsignedAttrs(token) + if err != nil { + return nil, fmt.Errorf("build unsigned attrs: %w", err) + } + return unsignedAttrs, nil +} + +// marshalSignedData assembles and marshals the CMS SignedData and ContentInfo wrapper. +func marshalSignedData(cert *x509.Certificate, chain []*x509.Certificate, siBytes []byte, digestAlg pkix.AlgorithmIdentifier) ([]byte, error) { // Build certificates var certsBytes []byte - allCerts := append([]*x509.Certificate{cert}, signer.Chain...) + allCerts := append([]*x509.Certificate{cert}, chain...) for _, c := range allCerts { certsBytes = append(certsBytes, c.Raw...) } diff --git a/signature/timestamp.go b/signature/timestamp.go index 0d3a39b..d945136 100644 --- a/signature/timestamp.go +++ b/signature/timestamp.go @@ -1,9 +1,132 @@ package signature -// TSA (Time Stamp Authority) support for RFC 3161. -// This is a placeholder for future implementation. -// When tsaURL is provided in options, the timestamp token -// would be fetched and embedded in the CMS unsigned attributes. +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "io" + "math/big" + "net/http" +) -// Note: Full TSA implementation requires HTTP client and ASN.1 -// request/response handling. This will be implemented when needed. +// OIDs for RFC 3161 timestamping. +var ( + oidTSTInfo = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 1, 4} + oidAttributeTimeStampToken = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 14} +) + +// ASN.1 structures for RFC 3161. + +type timeStampReq struct { + Version int + MessageImprint messageImprint + Nonce *big.Int `asn1:"optional"` + CertReq bool `asn1:"optional,default:false"` +} + +type messageImprint struct { + HashAlgorithm pkix.AlgorithmIdentifier + HashedMessage []byte +} + +type timeStampResp struct { + Status pkiStatusInfo + TimeStampToken asn1.RawValue `asn1:"optional"` +} + +type pkiStatusInfo struct { + Status int +} + +// fetchTimestamp sends an RFC 3161 timestamp request to the given TSA URL. +// It hashes the provided signature value, sends the request, and returns +// the raw DER-encoded timestamp token (ContentInfo). +func fetchTimestamp(tsaURL string, sig []byte) ([]byte, error) { + h := sha256.Sum256(sig) + + nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64)) + if err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + + req := timeStampReq{ + Version: 1, + MessageImprint: messageImprint{ + HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: oidSHA256}, + HashedMessage: h[:], + }, + Nonce: nonce, + CertReq: true, + } + + reqDER, err := asn1.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal timestamp request: %w", err) + } + + httpReq, err := http.NewRequest("POST", tsaURL, bytes.NewReader(reqDER)) + if err != nil { + return nil, fmt.Errorf("create HTTP request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/timestamp-query") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("TSA request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TSA returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read TSA response: %w", err) + } + + var tsResp timeStampResp + _, err = asn1.Unmarshal(body, &tsResp) + if err != nil { + return nil, fmt.Errorf("unmarshal timestamp response: %w", err) + } + + if tsResp.Status.Status > 1 { + return nil, fmt.Errorf("TSA error status: %d", tsResp.Status.Status) + } + + if len(tsResp.TimeStampToken.FullBytes) == 0 { + return nil, fmt.Errorf("no timestamp token in response") + } + + return tsResp.TimeStampToken.FullBytes, nil +} + +// buildUnsignedAttrs creates the unsigned attributes containing the timestamp token. +// Returns DER-encoded IMPLICIT [1] SET OF Attribute. +func buildUnsignedAttrs(timestampToken []byte) ([]byte, error) { + attr := attribute{ + Type: oidAttributeTimeStampToken, + Values: asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSet, + IsCompound: true, + Bytes: timestampToken, + }, + } + + attrBytes, err := asn1.Marshal(attr) + if err != nil { + return nil, fmt.Errorf("marshal timestamp attribute: %w", err) + } + + return asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassContextSpecific, + Tag: 1, + IsCompound: true, + Bytes: attrBytes, + }) +} diff --git a/signature/timestamp_test.go b/signature/timestamp_test.go new file mode 100644 index 0000000..ec6b7cb --- /dev/null +++ b/signature/timestamp_test.go @@ -0,0 +1,333 @@ +package signature + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/x509/pkix" + "encoding/asn1" + "io" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// mockTSAServer creates a test HTTP server that responds with a minimal RFC 3161 timestamp response. +func mockTSAServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if r.Header.Get("Content-Type") != "application/timestamp-query" { + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Parse TimeStampReq + var req timeStampReq + _, err = asn1.Unmarshal(body, &req) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + // Build minimal TSTInfo + type tstInfo struct { + Version int + Policy asn1.ObjectIdentifier + MessageImprint messageImprint + SerialNumber *big.Int + GenTime time.Time `asn1:"generalized"` + Nonce *big.Int `asn1:"optional"` + } + + tst := tstInfo{ + Version: 1, + Policy: asn1.ObjectIdentifier{1, 2, 3, 4, 1}, + MessageImprint: req.MessageImprint, + SerialNumber: big.NewInt(1), + GenTime: time.Now().UTC(), + Nonce: req.Nonce, + } + + tstInfoDER, err := asn1.Marshal(tst) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Sign the TSTInfo with a test TSA certificate to produce a valid CMS structure + tsaSigner, err := GenerateTestCertificate() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + h := sha256.Sum256(tstInfoDER) + + digestAlg := pkix.AlgorithmIdentifier{Algorithm: oidSHA256} + sigAlg, _ := signatureAlgorithm(tsaSigner.PrivateKey) + + signTime := time.Now().UTC() + attrs, _ := buildSignedAttrs(h[:], signTime) + attrsBytes, _ := marshalAttributes(attrs) + + attrsBytesForSign := make([]byte, len(attrsBytes)) + copy(attrsBytesForSign, attrsBytes) + attrsBytesForSign[0] = 0x31 + + attrHash := sha256.Sum256(attrsBytesForSign) + sig, _ := computeSignature(tsaSigner.PrivateKey, attrHash[:]) + + siBytes, _ := buildSignerInfoBytes(tsaSigner.Certificate, attrsBytes, sig, digestAlg, sigAlg, nil) + + // Build certificates + certsBytes := tsaSigner.Certificate.Raw + + digestAlgBytes, _ := asn1.Marshal(digestAlg) + digestAlgSetBytes, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, Tag: asn1.TagSet, IsCompound: true, Bytes: digestAlgBytes, + }) + siSetBytes, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, Tag: asn1.TagSet, IsCompound: true, Bytes: siBytes, + }) + + // Build SignedData with TSTInfo as encapsulated content + eContentBytes, _ := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: tstInfoDER, + }) + eci := struct { + EContentType asn1.ObjectIdentifier + EContent asn1.RawValue `asn1:"optional,explicit,tag:0"` + }{ + EContentType: oidTSTInfo, + EContent: asn1.RawValue{FullBytes: eContentBytes}, + } + eciBytes, _ := asn1.Marshal(eci) + + sd := signedData{ + Version: 3, + DigestAlgorithms: asn1.RawValue{FullBytes: digestAlgSetBytes}, + EncapContentInfo: encapContentInfo{EContentType: oidTSTInfo}, + Certificates: asn1.RawValue{ + Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: certsBytes, + }, + SignerInfos: asn1.RawValue{FullBytes: siSetBytes}, + } + + // Override EncapContentInfo with proper eContent + sdBytes, _ := asn1.Marshal(sd) + // Replace EncapContentInfo in the marshaled bytes with the version that has eContent + _ = eciBytes // We use the simpler version for the mock + + token, _ := asn1.Marshal(contentInfo{ + ContentType: oidSignedData, + Content: asn1.RawValue{ + Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: sdBytes, + }, + }) + + // Build TimeStampResp + resp := struct { + Status pkiStatusInfo + Token asn1.RawValue `asn1:"optional"` + }{ + Status: pkiStatusInfo{Status: 0}, + Token: asn1.RawValue{FullBytes: token}, + } + + respDER, _ := asn1.Marshal(resp) + w.Header().Set("Content-Type", "application/timestamp-reply") + _, _ = w.Write(respDER) + })) +} + +func TestSign_WithTimestamp(t *testing.T) { + tsa := mockTSAServer(t) + defer tsa.Close() + + buf := generateTestPDF(t) + + signer, err := GenerateTestCertificate() + if err != nil { + t.Fatalf("GenerateTestCertificate: %v", err) + } + + signed, err := Sign(buf, signer, + WithReason("Timestamp test"), + WithTimestamp(tsa.URL), + ) + if err != nil { + t.Fatalf("Sign with timestamp: %v", err) + } + + if len(signed) <= len(buf) { + t.Error("signed PDF should be larger than original") + } + + // Parse and verify the signature + info, err := ParseSignatureInfo(signed) + if err != nil { + t.Fatalf("ParseSignatureInfo: %v", err) + } + + // Verify the CMS signature is still valid + if err := info.VerifyIntegrity(signed); err != nil { + t.Fatalf("VerifyIntegrity: %v", err) + } + + // Check that timestamp token was embedded + if !info.HasTimestamp { + t.Error("expected HasTimestamp to be true") + } + if len(info.TimestampToken) == 0 { + t.Error("expected non-empty TimestampToken") + } +} + +func TestSign_WithTimestamp_ECDSA(t *testing.T) { + tsa := mockTSAServer(t) + defer tsa.Close() + + pdfData := generateTestPDF(t) + signer, err := GenerateTestECCertificate() + if err != nil { + t.Fatalf("GenerateTestECCertificate: %v", err) + } + + signed, err := Sign(pdfData, signer, + WithTimestamp(tsa.URL), + ) + if err != nil { + t.Fatalf("Sign with timestamp (ECDSA): %v", err) + } + + info, err := ParseSignatureInfo(signed) + if err != nil { + t.Fatalf("ParseSignatureInfo: %v", err) + } + + if err := info.VerifyIntegrity(signed); err != nil { + t.Fatalf("VerifyIntegrity: %v", err) + } + + if !info.HasTimestamp { + t.Error("expected HasTimestamp to be true") + } +} + +func TestSign_WithTimestamp_TSAError(t *testing.T) { + // TSA server that returns an error + tsa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer tsa.Close() + + pdfData := generateTestPDF(t) + signer, err := GenerateTestCertificate() + if err != nil { + t.Fatalf("GenerateTestCertificate: %v", err) + } + + _, err = Sign(pdfData, signer, WithTimestamp(tsa.URL)) + if err == nil { + t.Error("expected error when TSA returns error") + } +} + +func TestSign_WithTimestamp_TSAUnreachable(t *testing.T) { + pdfData := generateTestPDF(t) + signer, err := GenerateTestCertificate() + if err != nil { + t.Fatalf("GenerateTestCertificate: %v", err) + } + + _, err = Sign(pdfData, signer, WithTimestamp("http://127.0.0.1:1")) + if err == nil { + t.Error("expected error when TSA is unreachable") + } +} + +func TestSign_WithTimestamp_TSARejection(t *testing.T) { + // TSA server that returns a rejection status + tsa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + Status pkiStatusInfo + }{ + Status: pkiStatusInfo{Status: 2}, // rejection + } + respDER, _ := asn1.Marshal(resp) + w.Header().Set("Content-Type", "application/timestamp-reply") + _, _ = w.Write(respDER) + })) + defer tsa.Close() + + pdfData := generateTestPDF(t) + signer, err := GenerateTestCertificate() + if err != nil { + t.Fatalf("GenerateTestCertificate: %v", err) + } + + _, err = Sign(pdfData, signer, WithTimestamp(tsa.URL)) + if err == nil { + t.Error("expected error when TSA rejects request") + } +} + +func TestFetchTimestamp(t *testing.T) { + tsa := mockTSAServer(t) + defer tsa.Close() + + // Create a dummy signature to timestamp + sig := make([]byte, 256) + _, _ = rand.Read(sig) + + token, err := fetchTimestamp(tsa.URL, sig) + if err != nil { + t.Fatalf("fetchTimestamp: %v", err) + } + + if len(token) == 0 { + t.Error("expected non-empty timestamp token") + } + + // Verify it's valid ASN.1 + var raw asn1.RawValue + _, err = asn1.Unmarshal(token, &raw) + if err != nil { + t.Fatalf("timestamp token is not valid ASN.1: %v", err) + } +} + +func TestBuildUnsignedAttrs(t *testing.T) { + // Build a dummy timestamp token + dummyToken := []byte{0x30, 0x03, 0x02, 0x01, 0x01} // minimal SEQUENCE + + attrs, err := buildUnsignedAttrs(dummyToken) + if err != nil { + t.Fatalf("buildUnsignedAttrs: %v", err) + } + + if len(attrs) == 0 { + t.Error("expected non-empty unsigned attrs") + } + + // Verify it's valid ASN.1 with context-specific tag [1] + var raw asn1.RawValue + _, err = asn1.Unmarshal(attrs, &raw) + if err != nil { + t.Fatalf("unsigned attrs is not valid ASN.1: %v", err) + } + if raw.Tag != 1 || raw.Class != asn1.ClassContextSpecific { + t.Errorf("expected context-specific [1], got class=%d tag=%d", raw.Class, raw.Tag) + } +} diff --git a/signature/verify.go b/signature/verify.go index 00a6930..1bf60ca 100644 --- a/signature/verify.go +++ b/signature/verify.go @@ -33,6 +33,10 @@ type SignatureInfo struct { MessageDigest []byte // from signed attributes RawSignature []byte // the cryptographic signature value SignedAttrsRaw []byte // DER-encoded signed attributes (for verification) + + // Timestamp fields + HasTimestamp bool // true if RFC 3161 timestamp token is present + TimestampToken []byte // raw DER of the timestamp token (ContentInfo) } // ParseSignatureInfo extracts signature information from a signed PDF. @@ -168,6 +172,7 @@ type cmsSignerInfo struct { SignedAttrs asn1.RawValue `asn1:"optional,tag:0"` SignatureAlg asn1.RawValue Signature []byte + UnsignedAttrs asn1.RawValue `asn1:"optional,tag:1"` } func (info *SignatureInfo) parseCMS() error { @@ -233,6 +238,12 @@ func (info *SignatureInfo) parseCMS() error { info.MessageDigest = extractMessageDigest(si.SignedAttrs.Bytes) } + // Parse unsigned attributes to detect timestamp token + if len(si.UnsignedAttrs.Bytes) > 0 { + info.TimestampToken = extractTimestampToken(si.UnsignedAttrs.Bytes) + info.HasTimestamp = len(info.TimestampToken) > 0 + } + return nil } @@ -251,6 +262,27 @@ func marshalAsSet(raw asn1.RawValue) []byte { return encoded } +// extractTimestampToken walks the unsigned attributes to find the timestamp token +// (OID 1.2.840.113549.1.9.16.2.14). +func extractTimestampToken(attrsBytes []byte) []byte { + rest := attrsBytes + for len(rest) > 0 { + var attr struct { + Type asn1.ObjectIdentifier + Values asn1.RawValue `asn1:"set"` + } + var err error + rest, err = asn1.Unmarshal(rest, &attr) + if err != nil { + break + } + if attr.Type.Equal(oidAttributeTimeStampToken) { + return attr.Values.Bytes + } + } + return nil +} + // extractMessageDigest walks the signed attributes to find messageDigest (OID 1.2.840.113549.1.9.4). func extractMessageDigest(attrsBytes []byte) []byte { rest := attrsBytes