Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
2 changes: 1 addition & 1 deletion internal/buildinfo/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 9 additions & 2 deletions signature/byterange.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")

Expand Down
40 changes: 37 additions & 3 deletions signature/cms.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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...)
}
Expand Down
135 changes: 129 additions & 6 deletions signature/timestamp.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
Loading
Loading