Add electronic signature

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-02-16 23:12:09 +01:00
parent e6f9d7aae2
commit c191d25e9a
58 changed files with 7557 additions and 292 deletions

167
pkg/esign/certgen.go Normal file
View 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
}

View 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">
&nbsp;
</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>

View 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
View 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
View 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
View 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),
&timestamp.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
}