Public/Set-VellumPdfSignature.ps1
|
function Set-VellumPdfSignature { <# .SYNOPSIS Stages a PAdES digital signature to be applied when the document is saved. .DESCRIPTION Builds a VellumPdf.Signing.PdfSignatureSettings from the supplied certificate and metadata and stages it on the document. The signature is applied by Save-VellumPdfDocument, which signs the document while writing the file (VellumPdf signs at serialization time via SigningExtensions.Sign; a signature cannot be added to an already-written document through this module). The signature is a PAdES baseline signature (SubFilter ETSI.CAdES.detached). Supplying -TimestampUrl adds an RFC-3161 timestamp from a Time-Stamping Authority, upgrading the signature from PAdES B-B to PAdES B-T so the signing time is independently attested rather than claimed by the signer's clock. PDF/A conformance and signing compose: a PDF/A-2b document can be signed. Encryption and signing cannot be combined - the library rejects the combination at save time, and this cmdlet (and Protect-VellumPdfDocument) fail fast with a clear error instead. Calling Set-VellumPdfSignature again before saving replaces the staged signature settings, consistent with Set-* semantics. LEVELS: -Level selects the PAdES baseline level. B-B (default without a timestamp) and B-T (default with one) are described above. B-LT also embeds the certificate chain and the OCSP/CRL evidence for it in a /DSS dictionary so the signature stays verifiable after the certificates expire; B-LTA adds a final document timestamp so the evidence itself is protected. B-LT/B-LTA need a timestamp and fetch revocation data over the network at save time (from the OCSP/CRL addresses in the certificate chain), unless you pass your own -RevocationClient. They reserve 32 KB for the signature; raise -EstimatedSignatureSize if a long chain does not fit. CERTIFICATE: any [X509Certificate2] with a private key works - from Get-PfxCertificate, the cert: drive (Cert:\CurrentUser\My\<thumbprint>), or X509CertificateLoader/X509Certificate2 .NET APIs. When the key lives elsewhere, pass the public certificate and either -ExternalPrivateKey (a key object you can use in-process, such as a CNG or PKCS#11 key) or -ExternalSigner (a remote service such as a cloud KMS or HSM). An external signer is asynchronous: implement IExternalSigner in a compiled type (Add-Type) and do not combine it with Add-VellumPdfDrawing. .PARAMETER Document The live VellumPdf document flowing through the pipeline. The same instance is returned after the signature settings are staged, enabling chaining. .PARAMETER Certificate The signing certificate. Must include a private key (HasPrivateKey) unless -ExternalPrivateKey or -ExternalSigner is given. Typical sources: Get-PfxCertificate -FilePath ./signer.pfx, or Get-Item Cert:\CurrentUser\My\<thumbprint>. .PARAMETER Reason Optional reason for signing, recorded in the signature dictionary (/Reason) and shown by PDF viewers (e.g. 'Approved', 'I am the author'). .PARAMETER Location Optional physical or logical location of signing, recorded as /Location. .PARAMETER ContactInfo Optional contact information for the signer (e.g. an email address), recorded as /ContactInfo. .PARAMETER SignerName Optional display name of the signer, recorded as /Name. When omitted, viewers typically fall back to the certificate subject. .PARAMETER SigningTime Optional claimed signing time recorded in the signature. When omitted the library uses the current time at save. A -TimestampUrl timestamp attests the time independently of this value. .PARAMETER TimestampUrl Optional RFC-3161 Time-Stamping Authority (TSA) URL. When supplied, the signature is timestamped over HTTP at save time, producing a PAdES B-T signature whose signing time a verifier can trust without relying on the signer's clock. Must be an http or https URL. The TSA is contacted during Save-VellumPdfDocument, so saving requires network access to the TSA. .PARAMETER TimestampTimeout Optional timeout for the TSA HTTP request, as a TimeSpan. When omitted the underlying HttpClient default applies. Only meaningful with -TimestampUrl. .PARAMETER TimestampRequestCertificate Whether to ask the TSA to embed its signing certificate in the timestamp token. Defaults to $true, which is what most verifiers need to validate the timestamp offline. Set to $false only for a TSA that rejects the request. Only meaningful with -TimestampUrl. .PARAMETER Level PAdES level: B-B, B-T, B-LT or B-LTA (see above). Default: B-T when a timestamp is configured, otherwise B-B. B-T and above require -TimestampUrl or -TimestampClient. .PARAMETER TimestampClient A custom RFC 3161 client (VellumPdf.Signing.ITimestampClient) instead of -TimestampUrl, for example one that talks to an internal TSA or a test double. .PARAMETER RevocationClient A custom OCSP/CRL source (VellumPdf.Signing.IRevocationClient) for B-LT/B-LTA. Default: the built-in HttpRevocationClient, which follows at most three redirects. .PARAMETER RevocationTimeout Timeout of the built-in revocation client. B-LT/B-LTA only. .PARAMETER SignaturePage The 1-based page that carries the invisible signature widget. Default 1. A page past the end of the document makes the save fail. .PARAMETER SubFilter Pades (default) writes ETSI.CAdES.detached, the PAdES profile. Pkcs7 writes the legacy adbe.pkcs7.detached, which makes no ETSI claim and keeps a CMS signing-time attribute that PAdES omits. .PARAMETER EstimatedSignatureSize Bytes reserved for the signature (1024..4194304). Default 8192, or 32768 for B-LT/B-LTA. .PARAMETER ExternalPrivateKey A private key object (AsymmetricAlgorithm) that signs in-process while -Certificate carries only the public part. .PARAMETER ExternalSigner A VellumPdf.Signing.IExternalSigner that signs the digest remotely (cloud KMS, HSM). Runs asynchronously. .EXAMPLE # Long-term validation with a timestamp $doc | Set-VellumPdfSignature -Certificate $cert -TimestampUrl https://tsa.example.com -Level B-LT .EXAMPLE $cert = Get-PfxCertificate -FilePath ./signer.pfx New-VellumPdfDocument | Add-VellumPdfParagraph -Text 'Signed content.' | Set-VellumPdfSignature -Certificate $cert -Reason 'Approved' | Save-VellumPdfDocument -Path ./signed.pdf .EXAMPLE # Sign a PDF/A-2b archival document with a certificate from the store $cert = Get-Item Cert:\CurrentUser\My\1234567890ABCDEF1234567890ABCDEF12345678 New-VellumPdfDocument -Conformance PdfA2b | Set-VellumPdfDocumentInfo -Title 'Contract' -Author 'Acme' | Add-VellumPdfParagraph -Text 'Terms.' -FontHandle $font | Set-VellumPdfSignature -Certificate $cert -Location 'Amsterdam' ` -ContactInfo 'legal@acme.example' | Save-VellumPdfDocument -Path ./contract.pdf .EXAMPLE # PAdES B-T: add an RFC-3161 timestamp from a TSA (needs network at save) $cert = Get-PfxCertificate -FilePath ./signer.pfx New-VellumPdfDocument | Add-VellumPdfParagraph -Text 'Timestamped content.' | Set-VellumPdfSignature -Certificate $cert -Reason 'Approved' ` -TimestampUrl 'http://timestamp.digicert.com' | Save-VellumPdfDocument -Path ./signed-bt.pdf .OUTPUTS VellumPdf.Layout.Document (the same instance, for chaining) #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Mutates an in-memory document object only; no external/system state change.')] [CmdletBinding()] [OutputType([VellumPdf.Layout.Document], [VellumPdf.Document.PdfDocument])] param( [Parameter(Mandatory, ValueFromPipeline)] [object]$Document, [Parameter(Mandatory, Position = 0)] [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [string]$Reason, [string]$Location, [string]$ContactInfo, [string]$SignerName, [System.Nullable[System.DateTimeOffset]]$SigningTime, [uri]$TimestampUrl, [System.Nullable[timespan]]$TimestampTimeout, [bool]$TimestampRequestCertificate = $true, # PAdES level. Default: B-T when a timestamp is configured, otherwise B-B. [ValidateSet('B-B', 'B-T', 'B-LT', 'B-LTA')] [string]$Level, # A custom RFC 3161 client (VellumPdf.Signing.ITimestampClient), instead of -TimestampUrl. [VellumPdf.Signing.ITimestampClient]$TimestampClient, # A custom OCSP/CRL source (VellumPdf.Signing.IRevocationClient) for B-LT/B-LTA. # Default: HttpRevocationClient. [VellumPdf.Signing.IRevocationClient]$RevocationClient, [System.Nullable[timespan]]$RevocationTimeout, # 1-based page that carries the (invisible) signature widget. Default 1. [ValidateRange(1, 100000)] [int]$SignaturePage, # Pades = ETSI.CAdES.detached (default); Pkcs7 = adbe.pkcs7.detached (legacy, no ETSI profile). [ValidateSet('Pades', 'Pkcs7')] [string]$SubFilter, # Size in bytes of the space reserved for the signature (engine default 8192; # raised automatically to 32768 for B-LT/B-LTA, which embed chains and revocation data). [ValidateRange(1024, 4194304)] [int]$EstimatedSignatureSize, # Sign with a key that is not in the certificate object (HSM, PKCS#11, CNG key), # in-process. -Certificate then only needs its public part. [System.Security.Cryptography.AsymmetricAlgorithm]$ExternalPrivateKey, # Sign through a remote service (VellumPdf.Signing.IExternalSigner), e.g. a cloud KMS. # Its SignAsync runs off the pipeline thread, so it must be a compiled type. [VellumPdf.Signing.IExternalSigner]$ExternalSigner ) process { try { Assert-VellumPdfDocumentOpen -Document $Document -CommandName 'Set-VellumPdfSignature' # The library rejects Encrypt() + Sign() at save time; fail fast here # with the same constraint so the error points at the right cmdlet. if ($Document.PSObject.Properties['PSVellumProtected']) { throw ('Set-VellumPdfSignature: encryption and digital signatures cannot be combined. ' + 'Remove the Protect-VellumPdfDocument call to sign this document.') } if ($ExternalPrivateKey -and $ExternalSigner) { throw 'Set-VellumPdfSignature: -ExternalPrivateKey and -ExternalSigner cannot be combined; choose one.' } if ($ExternalSigner -and $Document.PSObject.Properties['PSVellumHasDrawing']) { throw ('Set-VellumPdfSignature: -ExternalSigner signs asynchronously on other threads, which cannot run the ' + 'script blocks of Add-VellumPdfDrawing elements already in this document. Use -ExternalPrivateKey, or a certificate with its key.') } if (-not $Certificate.HasPrivateKey -and -not $ExternalPrivateKey -and -not $ExternalSigner) { throw ('Set-VellumPdfSignature: the certificate does not include a private key. ' + 'Signing requires the private key; load the certificate from a PFX ' + '(Get-PfxCertificate) or a store entry that has the key, or pass ' + '-ExternalPrivateKey / -ExternalSigner for a key held elsewhere.') } $settings = [VellumPdf.Signing.PdfSignatureSettings]::new() $settings.Certificate = $Certificate if ($PSBoundParameters.ContainsKey('Reason')) { $settings.Reason = $Reason } if ($PSBoundParameters.ContainsKey('Location')) { $settings.Location = $Location } if ($PSBoundParameters.ContainsKey('ContactInfo')) { $settings.ContactInfo = $ContactInfo } if ($PSBoundParameters.ContainsKey('SignerName')) { $settings.SignerName = $SignerName } if ($PSBoundParameters.ContainsKey('SigningTime')) { $settings.SigningTime = $SigningTime } if ($ExternalPrivateKey) { $settings.ExternalPrivateKey = $ExternalPrivateKey } if ($ExternalSigner) { $settings.ExternalSigner = $ExternalSigner } if ($PSBoundParameters.ContainsKey('SignaturePage')) { $settings.SignaturePage = $SignaturePage - 1 } if ($PSBoundParameters.ContainsKey('SubFilter')) { $settings.SubFilter = if ($SubFilter -eq 'Pkcs7') { [VellumPdf.Signing.PdfSignatureSettings]::SubFilterAdbePkcs7Detached } else { [VellumPdf.Signing.PdfSignatureSettings]::SubFilterEtsiCAdESDetached } } # Validate the timestamp parameters BEFORE mutating any document state # below, so a bad -TimestampUrl on a re-stage leaves the previously # staged signature (and its HttpClient) intact. if ($TimestampClient -and $PSBoundParameters.ContainsKey('TimestampUrl')) { throw 'Set-VellumPdfSignature: -TimestampUrl and -TimestampClient cannot be combined; choose one.' } $hasTimestamp = $TimestampClient -or $PSBoundParameters.ContainsKey('TimestampUrl') $levelName = if ($Level) { $Level.ToUpperInvariant() } elseif ($hasTimestamp) { 'B-T' } else { 'B-B' } if ($levelName -ne 'B-B' -and -not $hasTimestamp) { throw "Set-VellumPdfSignature: -Level $levelName needs a signature timestamp; add -TimestampUrl (or -TimestampClient)." } if ($Level -eq 'B-B' -and $hasTimestamp) { throw 'Set-VellumPdfSignature: -Level B-B carries no timestamp; drop -TimestampUrl/-TimestampClient or use -Level B-T.' } if ($RevocationClient -or $PSBoundParameters.ContainsKey('RevocationTimeout')) { if ($levelName -notin 'B-LT', 'B-LTA') { throw 'Set-VellumPdfSignature: -RevocationClient/-RevocationTimeout apply to -Level B-LT or B-LTA only.' } } if ($RevocationClient -and $PSBoundParameters.ContainsKey('RevocationTimeout')) { throw 'Set-VellumPdfSignature: -RevocationTimeout configures the default client; it cannot be combined with -RevocationClient.' } $settings.Level = [System.Enum]::Parse([VellumPdf.Signing.PadesLevel], ($levelName -replace '-', '_'), $true) if ($PSBoundParameters.ContainsKey('EstimatedSignatureSize')) { $settings.EstimatedSignatureSizeBytes = $EstimatedSignatureSize } elseif ($levelName -in 'B-LT', 'B-LTA') { # Long-term levels embed the chain and revocation evidence in the signature. $settings.EstimatedSignatureSizeBytes = 32768 } if ($levelName -in 'B-LT', 'B-LTA' -and $RevocationClient) { $settings.RevocationClient = $RevocationClient } if ($TimestampClient) { $settings.TimestampClient = $TimestampClient } if ($PSBoundParameters.ContainsKey('TimestampUrl')) { if (-not $TimestampUrl.IsAbsoluteUri -or $TimestampUrl.Scheme -notin @('http', 'https')) { throw ("Set-VellumPdfSignature: -TimestampUrl must be an absolute http or https URL; " + "got '$TimestampUrl'.") } } elseif ($PSBoundParameters.ContainsKey('TimestampTimeout') -or $PSBoundParameters.ContainsKey('TimestampRequestCertificate')) { throw ('Set-VellumPdfSignature: -TimestampTimeout and ' + '-TimestampRequestCertificate require -TimestampUrl.') } # Dispose any HttpClient stashed by a previous timestamp staging on this # document before replacing the staged signature, so re-staging does not # orphan a live HttpClient. $clientProp = $Document.PSObject.Properties['PSVellumTimestampHttpClient'] if ($clientProp -and $clientProp.Value) { $clientProp.Value.Dispose() $clientProp.Value = $null } # Same for the revocation client's HttpClient from a previous staging. $revProp = $Document.PSObject.Properties['PSVellumRevocationHttpClient'] if ($revProp -and $revProp.Value) { $revProp.Value.Dispose() $revProp.Value = $null } if ($levelName -in 'B-LT', 'B-LTA' -and -not $RevocationClient) { # The OCSP/CRL addresses come from the signing certificate chain. CRL hosts do # legitimately redirect (unlike a timestamp authority), so redirects stay on but # are capped, and the client is per signature rather than the engine's process-wide # shared one; Save-VellumPdfDocument disposes it with the document. Built here, after # all parameter validation, so a rejected call cannot leak it. $handler = [System.Net.Http.SocketsHttpHandler]::new() $handler.AllowAutoRedirect = $true $handler.MaxAutomaticRedirections = 3 $revocationHttpClient = [System.Net.Http.HttpClient]::new($handler, $true) $timeoutArg = if ($PSBoundParameters.ContainsKey('RevocationTimeout')) { $RevocationTimeout } else { $null } $settings.RevocationClient = [VellumPdf.Signing.HttpRevocationClient]::new($revocationHttpClient, $timeoutArg) if ($revProp) { $revProp.Value = $revocationHttpClient } else { $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumRevocationHttpClient', $revocationHttpClient)) } } if ($PSBoundParameters.ContainsKey('TimestampUrl')) { # The HttpTimestampClient holds this HttpClient and is invoked later # by Save-VellumPdfDocument, so it must outlive this cmdlet. It is # stashed on the document and disposed when the document is (see # Save-VellumPdfDocument), or replaced by the next staging above. # # -TimestampUrl is user-supplied, so harden the client: a TSA does # not legitimately redirect, and following one would let a hostile # URL bounce the save-time request at an internal host (SSRF). Disable # auto-redirect and bound the request so a black-hole TSA cannot stall # Save indefinitely. $handler = [System.Net.Http.SocketsHttpHandler]::new() $handler.AllowAutoRedirect = $false $httpClient = [System.Net.Http.HttpClient]::new($handler, $true) if ($PSBoundParameters.ContainsKey('TimestampTimeout')) { $httpClient.Timeout = $TimestampTimeout } $settings.TimestampClient = [VellumPdf.Signing.HttpTimestampClient]::new( $TimestampUrl, $httpClient, $TimestampRequestCertificate, $TimestampTimeout) if ($clientProp) { $clientProp.Value = $httpClient } else { $Document.PSObject.Properties.Add( [psnoteproperty]::new('PSVellumTimestampHttpClient', $httpClient)) } } # Stage for Save-VellumPdfDocument; Set-* semantics allow replacing a # previously staged signature. $existing = $Document.PSObject.Properties['PSVellumSignature'] if ($existing) { $existing.Value = $settings } else { $Document.PSObject.Properties.Add([psnoteproperty]::new('PSVellumSignature', $settings)) } $Document } catch { $PSCmdlet.ThrowTerminatingError((Resolve-VellumPdfErrorRecord -ErrorRecord $_ -CommandName 'Set-VellumPdfSignature')) } } } |