sign RFC5322 messages with a trusted time stamp
  • Go 98.7%
  • Shell 1.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Heiko Schlittermann 8a875a6887
All checks were successful
nagonag (Push) / nagonag (push) Successful in 3m11s
Merge pull request '[nagonag] update dependencies (master)' (#15) from nagonag/update-master into master
Reviewed-on: #15
2026-08-01 12:25:24 +02:00
.claude/memory chore: update lint and repository configuration 2026-07-21 16:37:14 +02:00
.forgejo/workflows new: add cicd nagonag 2026-08-01 12:05:25 +02:00
assets refactor!: rename module and command to mailseal 2026-07-22 16:57:46 +02:00
cmd/mailseal fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
doc fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
internal fix(verify): version-exact canon registry, strict FieldSet, corpus/regression guards ai:claude-sonnet-4-5 2026-07-25 05:47:00 +02:00
list-of-free-tsa@b2af59decf move list-of-free-tsa 2024-12-21 21:49:06 +01:00
man fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
testdata feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
TSA chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
.gitignore refactor!: rename module and command to mailseal 2026-07-22 16:57:46 +02:00
.gitmodules refactor:mail 2024-12-21 21:49:05 +01:00
.golangci.yml chore: update lint and repository configuration 2026-07-21 16:37:14 +02:00
AGENTS.md feat!: rename x-signature header to x-tsa-signature ai:claude-sonnet-4-5 2026-07-23 19:49:49 +02:00
api_test.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
authority.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
chain_test.go fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
doc.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
event.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
fuzz_verify_test.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
go.mod deps(master): update minor/patch dependencies 2026-08-01 10:09:29 +00:00
go.sum deps(master): update minor/patch dependencies 2026-08-01 10:09:29 +00:00
header.go fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
LICENSE chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
normalize.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
NOTICE chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
public_test.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
query.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
README.md feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
recipe_test.go fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
result.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
sign.go fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00
sign_e2e_test.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
sign_test_helpers_test.go feat: add self-chaining signature renewal via --chain ai:claude-sonnet-4-5 2026-07-24 08:31:18 +02:00
sign_verify_regression_test.go feat: v2 one-header-per-operation signature schema (Plan 4) ai:gpt-5.6-terra 2026-07-25 10:58:50 +02:00
token.go chore: add Apache-2.0 LICENSE, NOTICE and SPDX headers ai:claude-sonnet-4-5 2026-07-23 20:56:39 +02:00
verify.go fix: harden same-time chain resolution ai:gpt-5.6-sol 2026-07-27 23:04:24 +02:00

Go Reference

mailseal — durable proof of existence for email; simple, portable, long-lived, and reproducible

mailseal

Turn ordinary email into durable, independently verifiable evidence — with one command and no service to operate.

mailseal reads an RFC 5322 message, creates a reproducible canonical representation, obtains one or more trusted RFC 3161 timestamps, validates them, and embeds the proof in an x-tsa-signature header. The result is a self-contained email that can be verified later without a mailseal database or daemon.

One binary. Standard input and output. Open formats. Explicit trust. Use it as a Unix-style command or import the same security-critical implementation as a Go package.

Why mailseal?

Principle What it means here
KISSS — Keep It Simple, Secure & Small Pipe a message in and get a sealed message out. No database, sidecar, agent, or proprietary service is required.
Portable A standalone Go executable with embedded manuals; the same core is available as an importable Go package. HTTP(S) and raw-DER TCP TSA transports are supported.
Long-lived Built on durable, independently implemented standards: RFC 5322 email, DKIM relaxed canonicalization, SHA-512 today (the verifier reads the algorithm from the token, so future tokens are not tied to it), RFC 3161, CMS, ASN.1 DER, and X.509. Tokens can be cross-checked with OpenSSL.
Reproducible The same message and field policy produce the same canonical bytes and message imprint. Tests use local deterministic fixtures, and generated manuals are reproducible.
Explicit trust TSA responses, signed tokens, certificate chains, and message imprints are checked before output. Library callers choose authorities and trust roots explicitly.
Self-contained evidence The standard timestamp token travels inside the email instead of depending on application state that may disappear later.

Build

go build -o mailseal ./cmd/mailseal

The module root is also the importable Go package; the command remains a single executable with its manuals embedded.

Usage

Sign a message

mailseal sign <message.eml >signed.eml
mailseal sign --output-format header <message.eml          # output only the signature header
mailseal sign --output-format token <message.eml >token    # output raw DER token
mailseal sign --output-format response <message.eml >tsr   # output full TSP response
mailseal sign --max 3 <message.eml >signed.eml      # 3 sibling tokens in one header
mailseal sign --chain --max 2 <signed.eml >renewed.eml # one chained header with 2 siblings
mailseal sign --header x-timestamp <message.eml     # custom header field name
mailseal sign --cert=false <message.eml >signed.eml # don't embed signing cert
mailseal sign --chain <signed.eml >renewed.eml      # renew: fold prior x-tsa-signature(s) into the new stamp

--chain (alias --renew) requests self-chaining renewal: when the input already carries one or more x-tsa-signature headers, the new timestamp's imprint additionally covers those prior signature headers, so the fresh stamp cryptographically attests that the earlier ones existed intact. This defends against future algorithm decay by periodically re-timestamping with a stronger hash or signature scheme over the whole prior chain. Without --chain (the default), re-signing an already-signed message produces an independent second stamp over the original content only, exactly as before — chaining is opt-in so existing workflows never change unexpectedly.

Inspect a signed message

mailseal verify <signed.eml

verify cryptographically checks each x-tsa-signature header: it enforces the supported header version and field selection, validates the RFC 3161 signed token (CMS signature and certificate chain) against the system trust store at the token's generation time, and confirms the token's message imprint matches the freshly normalized message. It exits nonzero if any check fails or if no signature header is present.

When a message carries a self-chained series of x-tsa-signature headers (see --chain above), verify reconstructs the chain oldest-to-newest from each token's authenticated generation time (never header position, so a relay reordering the headers does not matter) and validates every link. It reports the highest contiguous verified generation rather than claiming full-chain validity when an interior link is broken.

Read the manual

mailseal man       # mailseal(1)
mailseal man 5     # mailseal-signature(5)

When stdout is redirected, the command emits the generated roff source.

Normalize a message

Outputs the message in DKIM "relaxed" canonical form (as used for hashing before signing). Useful for debugging and cross-verification with OpenSSL.

mailseal normalize <message.eml >normalized.eml
mailseal normalize --fields from,date,subject <message.eml

Create a timestamp query

Outputs the binary ASN.1 timestamp query (for manual TSA interaction).

mailseal query <message.eml >timestamp.tsq
mailseal query --cert=false <message.eml >timestamp.tsq

Global flags

mailseal --verbose sign <message.eml # verbose output
mailseal -v sign <message.eml        # short form
mailseal --debug sign <message.eml   # debug output (implies verbose)

Go package

Applications can import go.schlittermann.de/heiko/mailseal directly. The library does not select public TSAs or load system roots: callers must explicitly choose authorities and the certificate roots they trust.

import (
    "crypto/x509"

    "go.schlittermann.de/heiko/mailseal"
)

roots, err := x509.SystemCertPool()
if err != nil { /* handle error */ }
authority, err := mailseal.NewAuthority("https://tsa.example.invalid/rfc3161")
if err != nil { /* handle error */ }
result, err := mailseal.Sign(ctx, message, mailseal.SignOptions{
    Authorities: []mailseal.Authority{authority},
    Roots: roots,
})
if err == nil { err = result.Write(output, mailseal.FormatMessage) }

For the common case of "sign and write the signed message," mailseal.SignTo is shorthand for the Sign plus result.Write(w, mailseal.FormatMessage) pair above; use Sign directly when you need the timestamps, tokens, or responses.

mailseal.Normalize returns the canonical bytes and mailseal.Query returns a DER RFC 3161 request without terminal or diagnostic behavior. Verify likewise requires an explicit root pool and returns structured verification metadata.

How it works

Signing and independent verification operate on a normalized message. The normalization follows the DKIM "relaxed" canonicalization (RFC 6376 §3.4):

  • Header field names lowercased, whitespace around colons removed
  • Continuation lines unfolded, whitespace sequences collapsed
  • Body: trailing whitespace per line stripped, trailing empty lines removed
  • Everything normalized to CRLF line endings

A subset of header fields is signed (from, sender, date, to, cc, subject, message-id), sorted alphabetically in their canonicalized form.

The x-tsa-signature header contains a JSON payload with:

  • Version — format version (currently 2)
  • Canon, CanonVersion, Fields, and FieldSet — shared advisory recipe metadata
  • Created — advisory local signing time, for display only
  • Timestamps — one entry per accepted TSA response; each has advisory Endpoint, Status, and duplicate Time, plus its 128-byte-chunked DER Token

One signing operation produces exactly one header, so --max produces sibling tokens in one generation. A chained header hashes complete prior v2 header lines; an independent header after a chain remains independent in verification.

A self-chained (--chain) generation's imprint covers the default header fields, then the sorted canonicalized bytes of every strictly earlier x-tsa-signature line, then the body — never the header being created (that would be self-referential). Chain order for verification is reconstructed from each token's authenticated generation time, not from header position.

Technical details

See doc/technical-details.md for the full technical documentation, including:

  • Regulatory motivation (GoBD / Unveränderbarkeit)
  • Where to sign in the SMTP pipeline
  • MTA integration examples (Postfix, Exim)
  • Normalization, hashing, and RFC 3161 timestamps
  • Trust chain and verification

Known Time Stamp Authorities

The following TSAs are compiled into mailseal. Endpoint reachability and policy links last verified 2026-07-23. The Free and Commercial use columns summarise a best-effort reading of each operator's public policy; they are not legal advice and can change without notice. Verify the current terms with the operator before relying on a service.

TSA Enabled Free Commercial use Policy
rfc3161timestamp.globalsign.com/advanced TPS — de-facto public; no formal standalone ToS
timestamp.globalsign.com/tsa/r6advanced1 TPS — legacy Authenticode endpoint, same policy
freetsa.org/tsr freetsa.org — explicitly free; no rate limit stated; no SLA, single operator
freetsa.org:318 (TCP) Native RFC 3161 raw-DER TCP service; returned a valid granted timestamp on the 2026-07-23 check; no SLA, single operator
timestamp.acs.microsoft.com MS PKI — de-facto public; formally part of paid Azure Artifact Signing
timestamp.apple.com/ts01 ? Apple PKI — intended for Apple code signing; CA root not in third-party trust stores
timestamp.digicert.com DigiCert KB — de-facto public standard; no formal ToS
timestamp.entrust.net/TSS/RFC3161sha2TS As of the 2026-07-23 check this endpoint issues tokens signed by Sectigo's public TSA (Entrust delegated its public timestamping to Sectigo); same de-facto-public policy as the Sectigo row
timestamp.sectigo.com Sectigo — de-facto public; no formal standalone ToS
tsa.mesign.com ? MeSign TSA20 req/day/IP on free tier; paid plans for commercial use; jurisdiction: China
time.certum.pl Certum — eIDAS-qualified CA (Poland); de-facto public; no formal ToS for endpoint
zeitstempel.dfn.de DFN PKIDFN members only; commercial use prohibited by bylaws. Announced academic-network IP restriction (from 2026-06-23) was not yet enforced on the 2026-07-23 check
ca.signfiles.com/tsa/get.aspx ca.signfiles.com/tsatest service only: tokens are signed by a CA whose subject is literally "Do not use in production / Test"; not for real evidence

Legend: ✓ = yes, ✗ = no, ? = unclear / no public policy found

Cross-verification with OpenSSL

See doc/technical-details.md.

License

Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE.