Add electronic signature
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
167
pkg/esign/certgen.go
Normal file
167
pkg/esign/certgen.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
)
|
||||
|
||||
type (
|
||||
CertificateGenerator struct {
|
||||
HTML2PDFConverter *html2pdf.Converter
|
||||
}
|
||||
|
||||
certificateData struct {
|
||||
SignatureID string
|
||||
OrganizationID string
|
||||
SignerFullName string
|
||||
SignerEmail string
|
||||
SignerIPAddress string
|
||||
SignerUserAgent string
|
||||
DocumentType string
|
||||
DocumentTypeName string
|
||||
FileID string
|
||||
FileHash string
|
||||
Seal string
|
||||
SealVersion int
|
||||
ConsentText string
|
||||
SignedAt string
|
||||
TSAAuthority string
|
||||
TSATime string
|
||||
TSASerial string
|
||||
Events []certificateEvent
|
||||
}
|
||||
|
||||
certificateEvent struct {
|
||||
EventType string
|
||||
Source string
|
||||
Actor string
|
||||
IPAddress string
|
||||
OccurredAt string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed certificate.html.tmpl
|
||||
certificateTemplateHTML string
|
||||
|
||||
certificateTemplate = template.Must(template.New("certificate").Parse(certificateTemplateHTML))
|
||||
)
|
||||
|
||||
func (g *CertificateGenerator) Generate(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
events coredata.ElectronicSignatureEvents,
|
||||
) (io.Reader, error) {
|
||||
data := certificateData{
|
||||
SignatureID: signature.ID.String(),
|
||||
OrganizationID: signature.OrganizationID.String(),
|
||||
SignerFullName: ref.UnrefOrZero(signature.SignerFullName),
|
||||
SignerEmail: signature.SignerEmail,
|
||||
SignerIPAddress: ref.UnrefOrZero(signature.SignerIPAddress),
|
||||
SignerUserAgent: ref.UnrefOrZero(signature.SignerUserAgent),
|
||||
DocumentType: signature.DocumentType.String(),
|
||||
DocumentTypeName: signature.DocumentType.DisplayName(),
|
||||
FileID: signature.FileID.String(),
|
||||
FileHash: ref.UnrefOrZero(signature.FileHash),
|
||||
Seal: ref.UnrefOrZero(signature.Seal),
|
||||
SealVersion: signature.SealVersion,
|
||||
ConsentText: signature.ConsentText,
|
||||
}
|
||||
|
||||
if signature.SignedAt == nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate: signature %s has no signed_at timestamp", signature.ID)
|
||||
}
|
||||
data.SignedAt = signature.SignedAt.UTC().Format(time.RFC3339)
|
||||
|
||||
if len(signature.TSAToken) == 0 {
|
||||
return nil, fmt.Errorf("cannot generate certificate: signature %s has no TSA token", signature.ID)
|
||||
}
|
||||
|
||||
tsResp, err := timestamp.ParseResponse(signature.TSAToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse TSA token for signature %s: %w", signature.ID, err)
|
||||
}
|
||||
|
||||
data.TSATime = tsResp.Time.UTC().Format(time.RFC3339)
|
||||
data.TSASerial = tsResp.SerialNumber.String()
|
||||
data.TSAAuthority = tsaAuthorityName(tsResp)
|
||||
|
||||
for _, evt := range events {
|
||||
data.Events = append(
|
||||
data.Events,
|
||||
certificateEvent{
|
||||
EventType: evt.EventType.String(),
|
||||
Source: evt.EventSource.String(),
|
||||
Actor: evt.ActorEmail,
|
||||
IPAddress: evt.ActorIPAddress,
|
||||
OccurredAt: evt.OccurredAt.UTC().Format(time.RFC3339),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var htmlBuf bytes.Buffer
|
||||
if err := certificateTemplate.Execute(&htmlBuf, data); err != nil {
|
||||
return nil, fmt.Errorf("cannot render certificate template: %w", err)
|
||||
}
|
||||
|
||||
pdfReader, err := g.HTML2PDFConverter.GeneratePDF(
|
||||
ctx,
|
||||
htmlBuf.Bytes(),
|
||||
html2pdf.RenderConfig{
|
||||
PageFormat: html2pdf.PageFormatA4,
|
||||
Orientation: html2pdf.OrientationPortrait,
|
||||
MarginTop: html2pdf.NewMarginMillimeters(20),
|
||||
MarginBottom: html2pdf.NewMarginMillimeters(20),
|
||||
MarginLeft: html2pdf.NewMarginMillimeters(20),
|
||||
MarginRight: html2pdf.NewMarginMillimeters(20),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate PDF: %w", err)
|
||||
}
|
||||
|
||||
return pdfReader, nil
|
||||
}
|
||||
|
||||
func tsaAuthorityName(ts *timestamp.Timestamp) string {
|
||||
if len(ts.Certificates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
cert := ts.Certificates[0]
|
||||
|
||||
if len(cert.Subject.Organization) > 0 {
|
||||
org := strings.Join(cert.Subject.Organization, ", ")
|
||||
if cert.Subject.CommonName != "" {
|
||||
return org + " (" + cert.Subject.CommonName + ")"
|
||||
}
|
||||
return org
|
||||
}
|
||||
|
||||
return cert.Subject.CommonName
|
||||
}
|
||||
520
pkg/esign/certificate.html.tmpl
Normal file
520
pkg/esign/certificate.html.tmpl
Normal file
@@ -0,0 +1,520 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 9.5px;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* -- Top bar -- */
|
||||
.top-bar {
|
||||
border-top: 4px solid #000;
|
||||
padding-top: 10px;
|
||||
margin-bottom: 6px;
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
text-align: right;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cert-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
color: #000;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* -- Meta line (ID + Status) -- */
|
||||
.meta-line {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.meta-left {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.meta-right {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* -- Info rows -- */
|
||||
.info-row {
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* -- Info grid (multi-column) -- */
|
||||
.info-grid {
|
||||
display: table;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.info-col {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-col-left {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.info-col-mid {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.info-col-right {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
/* -- Section header bar -- */
|
||||
.section-bar {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.section-bar td {
|
||||
padding: 5px 6px;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
background: #e8e8e8;
|
||||
border-top: 2px solid #000;
|
||||
}
|
||||
|
||||
/* -- Signer content -- */
|
||||
.signer-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.signer-table td {
|
||||
padding: 6px 6px;
|
||||
vertical-align: top;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.signer-name {
|
||||
font-weight: 700;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
/* -- Events table -- */
|
||||
.events-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.events-table thead td {
|
||||
padding: 5px 6px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
background: #e8e8e8;
|
||||
border-top: 2px solid #000;
|
||||
}
|
||||
|
||||
.events-table tbody td {
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* -- Detail table (key-value) -- */
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
padding: 3px 6px;
|
||||
vertical-align: top;
|
||||
font-size: 9px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.detail-table .lbl {
|
||||
width: 170px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* -- Mono -- */
|
||||
.mono {
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 8.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* -- Consent -- */
|
||||
.consent-box {
|
||||
padding: 6px;
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* -- Verification -- */
|
||||
.verification-box {
|
||||
padding: 6px;
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.verification-box ol {
|
||||
margin: 4px 0 0 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -- ERSD -- */
|
||||
.ersd-section {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.ersd-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: #000;
|
||||
border-bottom: 2px solid #000;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ersd-body {
|
||||
font-size: 9.5px;
|
||||
color: #000;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ersd-body p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ersd-body h3 {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ersd-body ul {
|
||||
margin: 2px 0 6px 18px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -- Footer -- */
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid #999;
|
||||
font-size: 8px;
|
||||
color: #777;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Top bar with title and brand -->
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-left">
|
||||
<div class="cert-title">Certificate Of Completion</div>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<div class="brand">probo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signature ID + Status -->
|
||||
<div class="meta-line">
|
||||
<div class="meta-left">
|
||||
Signature Id: {{.SignatureID}}
|
||||
</div>
|
||||
<div class="meta-right">
|
||||
<strong>Status: Completed</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subject -->
|
||||
<div class="info-row">
|
||||
<strong>Subject:</strong> {{.DocumentTypeName}}
|
||||
</div>
|
||||
|
||||
<!-- Document / Source info grid -->
|
||||
<div class="info-grid" style="margin-top: 4px;">
|
||||
<div class="info-col info-col-left">
|
||||
<div>Document Type: {{.DocumentType}}</div>
|
||||
<div>File Hash (SHA-256):</div>
|
||||
<div class="mono">{{.FileHash}}</div>
|
||||
</div>
|
||||
<div class="info-col info-col-mid">
|
||||
|
||||
</div>
|
||||
<div class="info-col info-col-right">
|
||||
<div>Organization Id:</div>
|
||||
<div class="mono">{{.OrganizationID}}</div>
|
||||
<div style="margin-top: 2px;">File Id:</div>
|
||||
<div class="mono">{{.FileID}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signer Events -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td style="width: 40%;">Signer Events</td>
|
||||
<td style="width: 60%;">Timestamp</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="signer-table">
|
||||
<tr>
|
||||
<td style="width: 40%;">
|
||||
<div class="signer-name">{{.SignerFullName}}</div>
|
||||
<div>{{.SignerEmail}}</div>
|
||||
<div>Security Level: Email, Account Authentication</div>
|
||||
<div>Using IP Address: {{.SignerIPAddress}}</div>
|
||||
<div style="margin-top: 6px; font-size: 8.5px;">
|
||||
<strong>Electronic Record and Signature Disclosure:</strong><br/>
|
||||
Accepted
|
||||
</div>
|
||||
</td>
|
||||
<td style="width: 60%;">
|
||||
<div><strong>Signed:</strong> {{.SignedAt}}</div>
|
||||
<div style="margin-top: 8px; font-size: 8.5px;">
|
||||
User Agent: {{.SignerUserAgent}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Electronic Consent -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Electronic Consent</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="consent-box">{{.ConsentText}}</div>
|
||||
|
||||
<!-- Signing Summary Events -->
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Signing Summary Events</td>
|
||||
<td>Status</td>
|
||||
<td>Timestamps</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Events}}
|
||||
<tr>
|
||||
<td>{{.EventType}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.OccurredAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Integrity Verification -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Integrity Verification</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="detail-table">
|
||||
<tr>
|
||||
<td class="lbl">Seal (SHA-256, v{{.SealVersion}})</td>
|
||||
<td><span class="mono">{{.Seal}}</span></td>
|
||||
</tr>
|
||||
{{if .TSAAuthority}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Authority</td>
|
||||
<td>{{.TSAAuthority}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .TSATime}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Timestamp</td>
|
||||
<td>{{.TSATime}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .TSASerial}}
|
||||
<tr>
|
||||
<td class="lbl">TSA Serial Number</td>
|
||||
<td>{{.TSASerial}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<!-- Seal Verification -->
|
||||
<table class="section-bar">
|
||||
<tr>
|
||||
<td>Seal Verification (v{{.SealVersion}})</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="verification-box">
|
||||
To independently verify the integrity seal, compute the SHA-256 hash of
|
||||
the following fields joined by newline characters (<span class="mono">\n</span>),
|
||||
in this exact order:
|
||||
<ol>
|
||||
<li>Signature ID</li>
|
||||
<li>Organization ID</li>
|
||||
<li>Document Type (the raw value, e.g. <span class="mono">NDA</span>)</li>
|
||||
<li>File ID</li>
|
||||
<li>File Hash (SHA-256, lowercase hex)</li>
|
||||
<li>Signer Full Name</li>
|
||||
<li>Signer Email (lowercased)</li>
|
||||
<li>Signer IP Address</li>
|
||||
<li>Signer User Agent</li>
|
||||
<li>Consent Text (the full text shown above)</li>
|
||||
<li>Signed At (UTC, RFC 3339 with nanoseconds, truncated to microsecond precision)</li>
|
||||
</ol>
|
||||
<div style="margin-top: 4px;">
|
||||
The resulting lowercase hex-encoded SHA-256 digest must match the seal
|
||||
shown above.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Electronic Record and Signature Disclosure -->
|
||||
<div class="ersd-section">
|
||||
<div class="ersd-title">Electronic Record and Signature Disclosure</div>
|
||||
<div class="ersd-body">
|
||||
<p>
|
||||
From time to time, the organization identified in this certificate (we, us, or
|
||||
the Company) may be required by law to provide to you certain written notices or
|
||||
disclosures. Described below are the terms and conditions for providing to you
|
||||
such notices and disclosures electronically through the Probo platform. Please
|
||||
read the information below carefully and thoroughly.
|
||||
</p>
|
||||
|
||||
<h3>Withdrawing your consent</h3>
|
||||
<p>
|
||||
If you decide to receive notices and disclosures from us electronically, you may
|
||||
at any time change your mind and tell us that you wish to withdraw your consent to
|
||||
receive notices and disclosures electronically. How you must inform us of your
|
||||
decision is described below.
|
||||
</p>
|
||||
|
||||
<h3>All notices and disclosures will be sent to you electronically</h3>
|
||||
<p>
|
||||
Unless you tell us otherwise in accordance with the procedures described herein, we
|
||||
will provide electronically to you through the Probo platform all required notices,
|
||||
disclosures, authorizations, acknowledgements, and other documents that are required
|
||||
to be provided or made available to you during the course of our relationship with
|
||||
you. To reduce the chance of you inadvertently not receiving any notice or
|
||||
disclosure, we prefer to provide all of the required notices and disclosures to you
|
||||
by the same method and to the same address that you have given us.
|
||||
</p>
|
||||
|
||||
<h3>How to contact us</h3>
|
||||
<p>
|
||||
You may contact us to let us know of your changes as to how we may contact you
|
||||
electronically, and to withdraw your prior consent to receive notices and
|
||||
disclosures electronically. To reach us, please send an email to
|
||||
<strong>legal@getprobo.com</strong>.
|
||||
</p>
|
||||
|
||||
<h3>To advise us of your new email address</h3>
|
||||
<p>
|
||||
To let us know of a change in your email address where we should send notices and
|
||||
disclosures electronically to you, you must send an email message to us at
|
||||
legal@getprobo.com and in the body of such request you must state your previous
|
||||
email address and your new email address. We do not require any other information
|
||||
from you to change your email address.
|
||||
</p>
|
||||
|
||||
<h3>To withdraw your consent</h3>
|
||||
<p>
|
||||
To inform us that you no longer wish to receive future notices and disclosures in
|
||||
electronic format you may:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
decline to sign a document from within your signing session, and on the
|
||||
subsequent page, indicate you wish to withdraw your consent; or
|
||||
</li>
|
||||
<li>
|
||||
send us an email to legal@getprobo.com and in the body of such request you
|
||||
must state your email, full name, mailing address, and telephone number. We do
|
||||
not need any other information from you to withdraw consent. The consequences of
|
||||
your withdrawing consent for online documents will be that transactions may take
|
||||
a longer time to process.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Acknowledging your access and consent to receive and sign documents electronically</h3>
|
||||
<p>
|
||||
By signing the document electronically through the Probo platform, you confirm
|
||||
that:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
You can access and read this Electronic Record and Signature Disclosure; and
|
||||
</li>
|
||||
<li>
|
||||
You can print on paper this Electronic Record and Signature Disclosure, or save
|
||||
or send this Electronic Record and Signature Disclosure to a location where you
|
||||
can print it, for future reference and access; and
|
||||
</li>
|
||||
<li>
|
||||
Until or unless you notify us as described above, you consent to receive
|
||||
exclusively through electronic means all notices, disclosures, authorizations,
|
||||
acknowledgements, and other documents that are required to be provided or made
|
||||
available to you during the course of your relationship with us.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
This certificate was generated automatically by Probo. The integrity
|
||||
seal and TSA timestamp provide tamper-evident proof of the signing
|
||||
event. All information required to independently recompute and verify
|
||||
the seal is included in this document.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
375
pkg/esign/completion_certificate_worker.go
Normal file
375
pkg/esign/completion_certificate_worker.go
Normal file
@@ -0,0 +1,375 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/crypto/uuid"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.gearno.de/x/ref"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
|
||||
emails "go.probo.inc/probo/packages/emails"
|
||||
)
|
||||
|
||||
// EmailPresenterConfigFunc resolves the emails.PresenterConfig for the
|
||||
// organization that owns the given trust center.
|
||||
type EmailPresenterConfigFunc func(ctx context.Context, organizationID gid.GID) (emails.PresenterConfig, error)
|
||||
|
||||
type (
|
||||
CompletionCertificateWorker struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
certificateGen *CertificateGenerator
|
||||
presenterConfigFunc EmailPresenterConfigFunc
|
||||
bucket string
|
||||
logger *log.Logger
|
||||
interval time.Duration
|
||||
staleAfter time.Duration
|
||||
maxConcurrency int
|
||||
}
|
||||
|
||||
CompletionCertificateWorkerOption func(*CompletionCertificateWorker)
|
||||
)
|
||||
|
||||
const (
|
||||
certificateFilename = "certificate-of-completion.pdf"
|
||||
)
|
||||
|
||||
func WithCompletionCertificateWorkerInterval(d time.Duration) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) { w.interval = d }
|
||||
}
|
||||
|
||||
func WithCompletionCertificateWorkerStaleAfter(d time.Duration) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) { w.staleAfter = d }
|
||||
}
|
||||
|
||||
func WithCompletionCertificateWorkerMaxConcurrency(n int) CompletionCertificateWorkerOption {
|
||||
return func(w *CompletionCertificateWorker) {
|
||||
if n > 0 {
|
||||
w.maxConcurrency = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompletionCertificateWorker(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
certificateGen *CertificateGenerator,
|
||||
presenterConfigFunc EmailPresenterConfigFunc,
|
||||
bucket string,
|
||||
logger *log.Logger,
|
||||
opts ...CompletionCertificateWorkerOption,
|
||||
) *CompletionCertificateWorker {
|
||||
w := &CompletionCertificateWorker{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
certificateGen: certificateGen,
|
||||
presenterConfigFunc: presenterConfigFunc,
|
||||
bucket: bucket,
|
||||
logger: logger,
|
||||
interval: 10 * time.Second,
|
||||
staleAfter: 10 * time.Minute,
|
||||
maxConcurrency: 5,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) Run(ctx context.Context) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, w.maxConcurrency)
|
||||
)
|
||||
defer wg.Wait()
|
||||
|
||||
LOOP:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(w.interval):
|
||||
// From there we should not accept cancelations anymore.
|
||||
nonCancelableCtx := context.WithoutCancel(ctx)
|
||||
|
||||
w.recoverStaleCertificateRows(nonCancelableCtx)
|
||||
for {
|
||||
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
w.logger.ErrorCtx(nonCancelableCtx, "cannot process certificate", log.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var (
|
||||
signature coredata.ElectronicSignature
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := signature.LoadNextCompletedWithoutCertificateForUpdate(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
signature.CertificateProcessingStartedAt = &now
|
||||
signature.UpdatedAt = now
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
<-sem
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(signature coredata.ElectronicSignature) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
|
||||
if err := w.generateAndCommit(ctx, &signature); err != nil {
|
||||
if err := w.handleCertFailure(ctx, &signature, scope, err); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot handle certificate failure", log.Error(err))
|
||||
}
|
||||
}
|
||||
}(signature)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) generateAndCommit(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signature.ID)
|
||||
)
|
||||
|
||||
email, attachments, err := w.generateCertificate(ctx, signature, scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
signature.CertificateFileID = &attachments[1].FileID
|
||||
signature.UpdatedAt = time.Now()
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
event := signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeCertificateGenerated,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
)
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate event: %w", err)
|
||||
}
|
||||
|
||||
if err := email.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate email: %w", err)
|
||||
}
|
||||
|
||||
for _, attachment := range attachments {
|
||||
if err := attachment.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert email attachment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) generateCertificate(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
scope coredata.Scoper,
|
||||
) (*coredata.Email, coredata.EmailAttachments, error) {
|
||||
var (
|
||||
events = coredata.ElectronicSignatureEvents{}
|
||||
signedFile = coredata.File{}
|
||||
)
|
||||
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := events.LoadBySignatureID(ctx, conn, scope, signature.ID); err != nil {
|
||||
return fmt.Errorf("cannot load events: %w", err)
|
||||
}
|
||||
|
||||
if err := signedFile.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
|
||||
return fmt.Errorf("cannot load signed file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
certificatePDFReader, err := w.certificateGen.Generate(ctx, signature, events)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot generate certificate: %w", err)
|
||||
}
|
||||
|
||||
certificateOfCompletionFile := coredata.File{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.FileEntityType),
|
||||
OrganizationID: signature.OrganizationID,
|
||||
BucketName: w.bucket,
|
||||
MimeType: "application/pdf",
|
||||
FileName: certificateFilename,
|
||||
FileKey: uuid.MustNewV4().String(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
certificateOfCompletionFileSize, err := w.fileManager.PutFile(
|
||||
ctx,
|
||||
&certificateOfCompletionFile,
|
||||
certificatePDFReader,
|
||||
map[string]string{
|
||||
"type": "certificate-of-completion",
|
||||
"signature-id": signature.ID.String(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot upload cert to S3: %w", err)
|
||||
}
|
||||
|
||||
certificateOfCompletionFile.FileSize = certificateOfCompletionFileSize
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := certificateOfCompletionFile.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert certificate of completion file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
presenterCfg, err := w.presenterConfigFunc(ctx, signature.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot resolve presenter config: %w", err)
|
||||
}
|
||||
emailPresenter := emails.NewPresenterFromConfig(w.fileManager, presenterCfg, ref.UnrefOrZero(signature.SignerFullName))
|
||||
|
||||
docTypeName := signature.DocumentType.DisplayName()
|
||||
subject, textBody, htmlBody, err := emailPresenter.RenderElectronicSignatureCertificate(ctx, ref.UnrefOrZero(signature.SignerFullName), docTypeName)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot render email: %w", err)
|
||||
}
|
||||
|
||||
email := coredata.NewEmail(
|
||||
ref.UnrefOrZero(signature.SignerFullName),
|
||||
mail.Addr(signature.SignerEmail),
|
||||
subject,
|
||||
textBody,
|
||||
htmlBody,
|
||||
)
|
||||
|
||||
attachments := coredata.EmailAttachments{
|
||||
coredata.NewEmailAttachment(
|
||||
email.ID,
|
||||
signedFile.ID,
|
||||
signedFile.FileName,
|
||||
signedFile.MimeType,
|
||||
),
|
||||
coredata.NewEmailAttachment(
|
||||
email.ID,
|
||||
certificateOfCompletionFile.ID,
|
||||
certificateFilename,
|
||||
"application/pdf",
|
||||
),
|
||||
}
|
||||
|
||||
return email, attachments, nil
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) handleCertFailure(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
scope coredata.Scoper,
|
||||
processingError error,
|
||||
) error {
|
||||
w.logger.ErrorCtx(ctx, "certificate worker failure",
|
||||
log.Error(processingError),
|
||||
log.String("signature_id", signature.ID.String()),
|
||||
)
|
||||
|
||||
return w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
signature.CertificateProcessingStartedAt = nil
|
||||
signature.UpdatedAt = time.Now()
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (w *CompletionCertificateWorker) recoverStaleCertificateRows(ctx context.Context) {
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return coredata.ResetStaleCertificateProcessing(ctx, conn, w.staleAfter)
|
||||
},
|
||||
); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot recover stale certificates", log.Error(err))
|
||||
}
|
||||
}
|
||||
344
pkg/esign/sealing_worker.go
Normal file
344
pkg/esign/sealing_worker.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/hash"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLoadFile = errors.New("esign: cannot load file")
|
||||
ErrDownloadPDF = errors.New("esign: cannot download PDF")
|
||||
ErrComputeSeal = errors.New("esign: cannot compute seal")
|
||||
ErrTSATimestamp = errors.New("esign: cannot get TSA timestamp")
|
||||
)
|
||||
|
||||
const (
|
||||
currentSealVersion = 1
|
||||
)
|
||||
|
||||
type (
|
||||
SealingWorker struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
tsaClient *TSAClient
|
||||
logger *log.Logger
|
||||
interval time.Duration
|
||||
tsaTimeout time.Duration
|
||||
staleAfter time.Duration
|
||||
maxConcurrency int
|
||||
}
|
||||
|
||||
SealingWorkerOption func(*SealingWorker)
|
||||
)
|
||||
|
||||
func WithSealingWorkerInterval(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.interval = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerTSATimeout(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.tsaTimeout = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerStaleAfter(d time.Duration) SealingWorkerOption {
|
||||
return func(w *SealingWorker) { w.staleAfter = d }
|
||||
}
|
||||
|
||||
func WithSealingWorkerMaxConcurrency(n int) SealingWorkerOption {
|
||||
return func(w *SealingWorker) {
|
||||
if n > 0 {
|
||||
w.maxConcurrency = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewSealingWorker(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
tsaClient *TSAClient,
|
||||
logger *log.Logger,
|
||||
opts ...SealingWorkerOption,
|
||||
) *SealingWorker {
|
||||
w := &SealingWorker{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
tsaClient: tsaClient,
|
||||
logger: logger,
|
||||
interval: 10 * time.Second,
|
||||
tsaTimeout: 10 * time.Second,
|
||||
staleAfter: 5 * time.Minute,
|
||||
maxConcurrency: 5,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *SealingWorker) Run(ctx context.Context) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
sem = make(chan struct{}, w.maxConcurrency)
|
||||
)
|
||||
|
||||
defer wg.Wait()
|
||||
|
||||
LOOP:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(w.interval):
|
||||
// From there we should not accept cancelations anymore.
|
||||
nonCancelableCtx := context.WithoutCancel(ctx)
|
||||
|
||||
w.recoverStaleRows(nonCancelableCtx)
|
||||
for {
|
||||
if err := w.processNext(nonCancelableCtx, sem, &wg); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
w.logger.ErrorCtx(nonCancelableCtx, "cannot claim signature", log.Error(err))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
goto LOOP
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SealingWorker) processNext(ctx context.Context, sem chan struct{}, wg *sync.WaitGroup) error {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
var (
|
||||
signature = coredata.ElectronicSignature{}
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := signature.LoadNextAcceptedForUpdateSkipLocked(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signature.Status = coredata.ElectronicSignatureStatusProcessing
|
||||
signature.ProcessingStartedAt = &now
|
||||
signature.AttemptCount++
|
||||
signature.LastAttemptedAt = &now
|
||||
signature.UpdatedAt = now
|
||||
if err := signature.Update(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
<-sem
|
||||
return err
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(signature coredata.ElectronicSignature) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
if err := w.sealAndCommit(ctx, &signature); err != nil {
|
||||
if err := w.failSignature(ctx, &signature, err); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot fail signature", log.Error(err))
|
||||
}
|
||||
}
|
||||
}(signature)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SealingWorker) sealAndCommit(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signature.ID)
|
||||
file coredata.File
|
||||
events []coredata.ElectronicSignatureEvent
|
||||
)
|
||||
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := file.LoadByID(ctx, conn, scope, signature.FileID); err != nil {
|
||||
return fmt.Errorf("cannot load file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrLoadFile, err)
|
||||
}
|
||||
|
||||
pdfBytes, err := w.fileManager.GetFileBytes(ctx, &file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrDownloadPDF, err)
|
||||
}
|
||||
|
||||
fileHash := hash.SHA256Hex(pdfBytes)
|
||||
signature.FileHash = &fileHash
|
||||
|
||||
seal, err := signature.ComputeSeal(currentSealVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrComputeSeal, err)
|
||||
}
|
||||
signature.Seal = &seal
|
||||
signature.SealVersion = currentSealVersion
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeSealComputed,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
tsaCtx, cancel := context.WithTimeout(ctx, w.tsaTimeout)
|
||||
defer cancel()
|
||||
tsaToken, err := w.tsaClient.Timestamp(tsaCtx, []byte(seal))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrTSATimestamp, err)
|
||||
}
|
||||
signature.TSAToken = tsaToken
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeTimestampRequested,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
if err := w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var current coredata.ElectronicSignature
|
||||
if err := current.LoadByID(ctx, tx, scope, signature.ID); err != nil {
|
||||
return fmt.Errorf("cannot load signature: %w", err)
|
||||
}
|
||||
|
||||
if current.Status != coredata.ElectronicSignatureStatusProcessing {
|
||||
return fmt.Errorf("esign: unexpected status %s, expected PROCESSING", current.Status)
|
||||
}
|
||||
|
||||
signature.Status = coredata.ElectronicSignatureStatusCompleted
|
||||
signature.UpdatedAt = time.Now()
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
events = append(
|
||||
events,
|
||||
signature.NewEvent(
|
||||
coredata.ElectronicSignatureEventTypeSignatureCompleted,
|
||||
coredata.ElectronicSignatureEventSourceServer,
|
||||
),
|
||||
)
|
||||
|
||||
for i := range events {
|
||||
if err := events[i].Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert %s event: %w", events[i].EventType, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("cannot commit signing results: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *SealingWorker) failSignature(
|
||||
ctx context.Context,
|
||||
signature *coredata.ElectronicSignature,
|
||||
processingError error,
|
||||
) error {
|
||||
scope := coredata.NewScopeFromObjectID(signature.ID)
|
||||
|
||||
w.logger.ErrorCtx(ctx, "sealing worker failure",
|
||||
log.Error(processingError),
|
||||
log.String("signature_id", signature.ID.String()),
|
||||
)
|
||||
|
||||
return w.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
errStr := userFacingError(processingError)
|
||||
signature.LastError = &errStr
|
||||
signature.ProcessingStartedAt = nil
|
||||
signature.UpdatedAt = time.Now()
|
||||
if signature.AttemptCount >= signature.MaxAttempts {
|
||||
signature.Status = coredata.ElectronicSignatureStatusFailed
|
||||
} else {
|
||||
signature.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
}
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
event := signature.NewEvent(coredata.ElectronicSignatureEventTypeProcessingError, coredata.ElectronicSignatureEventSourceServer)
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert PROCESSING_ERROR event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func userFacingError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, ErrTSATimestamp):
|
||||
return "The timestamp authority is temporarily unavailable."
|
||||
case errors.Is(err, ErrLoadFile):
|
||||
return "Unable to load the document for signing."
|
||||
case errors.Is(err, ErrDownloadPDF):
|
||||
return "Unable to retrieve the document."
|
||||
case errors.Is(err, ErrComputeSeal):
|
||||
return "Unable to generate the cryptographic seal."
|
||||
default:
|
||||
return "An unexpected error occurred while processing your signature."
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SealingWorker) recoverStaleRows(ctx context.Context) {
|
||||
if err := w.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return coredata.ResetStaleProcessingSignatures(ctx, conn, w.staleAfter)
|
||||
},
|
||||
); err != nil {
|
||||
w.logger.ErrorCtx(ctx, "cannot recover stale signatures", log.Error(err))
|
||||
}
|
||||
}
|
||||
352
pkg/esign/service.go
Normal file
352
pkg/esign/service.go
Normal file
@@ -0,0 +1,352 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/httpclient"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/filemanager"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/html2pdf"
|
||||
"go.probo.inc/probo/pkg/mail"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// Service manages the electronic signature lifecycle.
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
fileManager *filemanager.Service
|
||||
tsaClient *TSAClient
|
||||
certificateGen *CertificateGenerator
|
||||
bucket string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
CreateSignatureRequest struct {
|
||||
OrganizationID gid.GID
|
||||
DocumentType coredata.ElectronicSignatureDocumentType
|
||||
FileID gid.GID
|
||||
SignerEmail mail.Addr
|
||||
ConsentText string // optional; required when DocumentType == OTHER
|
||||
}
|
||||
|
||||
AcceptSignatureRequest struct {
|
||||
SignatureID gid.GID
|
||||
SignerFullName string
|
||||
SignerEmail mail.Addr
|
||||
SignerIPAddr string
|
||||
SignerUA string
|
||||
}
|
||||
|
||||
RecordEventRequest struct {
|
||||
SignatureID gid.GID
|
||||
EventType coredata.ElectronicSignatureEventType
|
||||
EventSource coredata.ElectronicSignatureEventSource
|
||||
ActorEmail mail.Addr
|
||||
ActorIPAddr string
|
||||
ActorUA string
|
||||
}
|
||||
)
|
||||
|
||||
func NewService(
|
||||
pgClient *pg.Client,
|
||||
fileManager *filemanager.Service,
|
||||
html2pdfConverter *html2pdf.Converter,
|
||||
tsaURL string,
|
||||
bucket string,
|
||||
logger *log.Logger,
|
||||
) *Service {
|
||||
httpClient := httpclient.DefaultPooledClient(
|
||||
httpclient.WithLogger(logger),
|
||||
)
|
||||
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
fileManager: fileManager,
|
||||
tsaClient: &TSAClient{URL: tsaURL, HTTPClient: httpClient},
|
||||
certificateGen: &CertificateGenerator{
|
||||
HTML2PDFConverter: html2pdfConverter,
|
||||
},
|
||||
bucket: bucket,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, presenterConfigFunc EmailPresenterConfigFunc) error {
|
||||
g := errgroup.Group{}
|
||||
|
||||
sealingWorkerCtx, stopSealingWorker := context.WithCancel(context.Background())
|
||||
sealingWorker := NewSealingWorker(
|
||||
s.pg,
|
||||
s.fileManager,
|
||||
s.tsaClient,
|
||||
s.logger.Named("sealing-worker"),
|
||||
)
|
||||
g.Go(func() error { return sealingWorker.Run(sealingWorkerCtx) })
|
||||
|
||||
certWorkerCtx, stopCertWorker := context.WithCancel(context.Background())
|
||||
certWorker := NewCompletionCertificateWorker(
|
||||
s.pg,
|
||||
s.fileManager,
|
||||
s.certificateGen,
|
||||
presenterConfigFunc,
|
||||
s.bucket,
|
||||
s.logger.Named("completion-certificate-worker"),
|
||||
)
|
||||
g.Go(func() error { return certWorker.Run(certWorkerCtx) })
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
stopSealingWorker()
|
||||
stopCertWorker()
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
|
||||
// CreateSignatureRequest contains the parameters for creating a PENDING
|
||||
// electronic signature.
|
||||
|
||||
// CreateSignature creates a PENDING electronic signature row. The conn
|
||||
// parameter allows the caller to include this insert inside its own
|
||||
// transaction.
|
||||
func (s *Service) CreateSignature(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
req *CreateSignatureRequest,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
consentText := req.ConsentText
|
||||
if consentText == "" {
|
||||
var err error
|
||||
consentText, err = req.DocumentType.ConsentText()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot derive consent text: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Caller provided explicit text; append e-sign process consent
|
||||
// suffix if not already present.
|
||||
if !strings.HasSuffix(consentText, coredata.ESignProcessConsentText) {
|
||||
consentText = consentText + " " + coredata.ESignProcessConsentText
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
scope := coredata.NewScopeFromObjectID(req.OrganizationID)
|
||||
|
||||
sig := &coredata.ElectronicSignature{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEntityType),
|
||||
OrganizationID: req.OrganizationID,
|
||||
Status: coredata.ElectronicSignatureStatusPending,
|
||||
DocumentType: req.DocumentType,
|
||||
FileID: req.FileID,
|
||||
SignerEmail: req.SignerEmail.String(),
|
||||
ConsentText: consentText,
|
||||
SealVersion: 1,
|
||||
AttemptCount: 0,
|
||||
MaxAttempts: 10,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := sig.Insert(ctx, conn, scope); err != nil {
|
||||
return nil, fmt.Errorf("cannot insert electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptSignature(ctx context.Context, req *AcceptSignatureRequest) error {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(req.SignatureID)
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var signature coredata.ElectronicSignature
|
||||
if err := signature.LoadByID(ctx, tx, scope, req.SignatureID); err != nil {
|
||||
return fmt.Errorf("cannot load electronic signature: %w", err)
|
||||
}
|
||||
|
||||
if signature.Status != coredata.ElectronicSignatureStatusPending &&
|
||||
signature.Status != coredata.ElectronicSignatureStatusFailed {
|
||||
return fmt.Errorf("cannot accept electronic signature in status %s", signature.Status)
|
||||
}
|
||||
|
||||
// If retrying from FAILED, reset attempt tracking.
|
||||
if signature.Status == coredata.ElectronicSignatureStatusFailed {
|
||||
signature.AttemptCount = 0
|
||||
signature.LastError = nil
|
||||
}
|
||||
|
||||
signature.SignerFullName = &req.SignerFullName
|
||||
signature.SignerIPAddress = &req.SignerIPAddr
|
||||
signature.SignerUserAgent = &req.SignerUA
|
||||
signature.SignedAt = &now
|
||||
signature.Status = coredata.ElectronicSignatureStatusAccepted
|
||||
signature.UpdatedAt = now
|
||||
|
||||
if err := signature.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update signature: %w", err)
|
||||
}
|
||||
|
||||
s.recordEvent(
|
||||
ctx,
|
||||
tx,
|
||||
&RecordEventRequest{
|
||||
SignatureID: signature.ID,
|
||||
EventType: coredata.ElectronicSignatureEventTypeSignatureAccepted,
|
||||
EventSource: coredata.ElectronicSignatureEventSourceServer,
|
||||
ActorEmail: req.SignerEmail,
|
||||
ActorIPAddr: req.SignerIPAddr,
|
||||
ActorUA: req.SignerUA,
|
||||
},
|
||||
)
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) RecordEvent(ctx context.Context, req *RecordEventRequest) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return s.recordEvent(ctx, tx, req)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) recordEvent(ctx context.Context, tx pg.Conn, req *RecordEventRequest) error {
|
||||
var (
|
||||
now = time.Now()
|
||||
scope = coredata.NewScopeFromObjectID(req.SignatureID)
|
||||
)
|
||||
|
||||
event := coredata.ElectronicSignatureEvent{
|
||||
ID: gid.New(scope.GetTenantID(), coredata.ElectronicSignatureEventEntityType),
|
||||
ElectronicSignatureID: req.SignatureID,
|
||||
EventType: req.EventType,
|
||||
EventSource: req.EventSource,
|
||||
ActorEmail: req.ActorEmail.String(),
|
||||
ActorIPAddress: req.ActorIPAddr,
|
||||
ActorUserAgent: req.ActorUA,
|
||||
OccurredAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := event.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert signing event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSignatureByID(ctx context.Context, id gid.GID) (*coredata.ElectronicSignature, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(id)
|
||||
signature = coredata.ElectronicSignature{}
|
||||
)
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := signature.LoadByID(ctx, conn, scope, id); err != nil {
|
||||
return fmt.Errorf("cannot load electronic signature: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &signature, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadSignatureByOrgEmailAndDocType(
|
||||
ctx context.Context,
|
||||
orgID gid.GID,
|
||||
email string,
|
||||
docType coredata.ElectronicSignatureDocumentType,
|
||||
fileID gid.GID,
|
||||
) (*coredata.ElectronicSignature, error) {
|
||||
scope := coredata.NewScopeFromObjectID(orgID)
|
||||
var sig coredata.ElectronicSignature
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return sig.LoadByOrgEmailAndDocType(ctx, conn, scope, orgID, email, docType, fileID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sig, nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateCertificateFileURL(
|
||||
ctx context.Context,
|
||||
certificateFileID gid.GID,
|
||||
expiresIn time.Duration,
|
||||
) (string, error) {
|
||||
scope := coredata.NewScopeFromObjectID(certificateFileID)
|
||||
var file coredata.File
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return file.LoadByID(ctx, conn, scope, certificateFileID)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load certificate file: %w", err)
|
||||
}
|
||||
|
||||
url, err := s.fileManager.GenerateFileUrl(ctx, &file, expiresIn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate certificate file URL: %w", err)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadEventsBySignatureID(
|
||||
ctx context.Context,
|
||||
signatureID gid.GID,
|
||||
) (coredata.ElectronicSignatureEvents, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(signatureID)
|
||||
events = coredata.ElectronicSignatureEvents{}
|
||||
)
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
if err := events.LoadBySignatureID(ctx, conn, scope, signatureID); err != nil {
|
||||
return fmt.Errorf("cannot load events: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
83
pkg/esign/tsa.go
Normal file
83
pkg/esign/tsa.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package esign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/digitorus/timestamp"
|
||||
"go.gearno.de/kit/httpclient"
|
||||
)
|
||||
|
||||
// TSAClient sends RFC 3161 timestamp requests to a Trusted Timestamp Authority.
|
||||
type TSAClient struct {
|
||||
URL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Timestamp sends an RFC 3161 TimeStampReq via HTTP POST to the TSA.
|
||||
// The data parameter is the raw bytes to timestamp (typically the seal hex
|
||||
// string as UTF-8 bytes). CreateRequest internally computes SHA-256(data)
|
||||
// to build the MessageImprint. Returns the raw DER-encoded TimeStampResp bytes.
|
||||
func (c *TSAClient) Timestamp(ctx context.Context, data []byte) ([]byte, error) {
|
||||
tsReq, err := timestamp.CreateRequest(
|
||||
bytes.NewReader(data),
|
||||
×tamp.RequestOptions{
|
||||
Hash: crypto.SHA256,
|
||||
Certificates: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot create timestamp request: %w", err)
|
||||
}
|
||||
|
||||
httpClient := c.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = httpclient.DefaultPooledClient()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(tsReq))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot build TSA HTTP request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/timestamp-query")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: TSA request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("esign: TSA returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("esign: cannot read TSA response: %w", err)
|
||||
}
|
||||
|
||||
// Validate the response: checks PKIStatus and parses the signed TSTInfo.
|
||||
if _, err := timestamp.ParseResponse(respBytes); err != nil {
|
||||
return nil, fmt.Errorf("esign: invalid TSA response: %w", err)
|
||||
}
|
||||
|
||||
return respBytes, nil
|
||||
}
|
||||
Reference in New Issue
Block a user