Time-Stamp Protocol (RFC 3161, RFC 5816) in pure Elixir: build requests, ask a time-stamping authority (TSA), read the answers, and verify a time-stamp at the date it was issued, even after the certificate of the TSA has expired. It also puts time-stamps in signatures made with cmsex.
Everything but the hash and signature operations is written in Elixir on top
of OTP's :crypto, :public_key and :ssl. There is no NIF and no other
dependency than cmsex.
Installation
def deps do
[{:tspex, "~> 0.1"}]
endElixir 1.16 and OTP 26 or later.
A time-stamp of a document
Tspex.stamp/2 builds the request, sends it, reads the answer and verifies
it. :trust is required: it is the list of root certificates (in DER) you
accept for the TSA.
tsa_root = File.read!("tsa-root.crt")
contract = File.read!("contract.pdf")
{:ok, stamp} =
Tspex.stamp(contract, url: "https://tsa.example.com/tsp", trust: [tsa_root])
stamp.info.gen_time
File.write!("contract.pdf.tst", stamp.token)The token is the time-stamp proper. Keep it with the document. Months or years
later, Tspex.verify/3 checks the pair, judging the chain of the TSA at the
date in the token and not today:
token = File.read!("contract.pdf.tst")
{:ok, stamp} = Tspex.verify(token, {:data, contract}, trust: [tsa_root])
stamp.info.gen_timeA document that was changed is {:error, %Tspex.Error{reason: :imprint_mismatch}}.
What {:ok, stamp} promises
The token is a SignedData with one signer; the signer's certificate is the
one the token names, is meant for time-stamping (a critical extKeyUsage with
id-kp-timeStamping), and chains to one of your roots as of the genTime;
the signature is valid; the imprint is the hash of your data. It does not
promise that the TSA is honest or its clock right (the roots are your choice
and the time is its word), that the certificate of the TSA was not revoked
(stamp.revocation says :not_checked unless you give :revocation), or that
the stamp will prove anything for ever. The documentation of Tspex.verify/3
and SECURITY.md have the full list.
Your own HTTP client
Tspex.stamp/2 sends the request with Tspex.HTTP, a small HTTP/1.1 client
that limits the size of the answer. To use Req, Finch, a proxy or a mock,
give a :transport, or do the steps yourself:
request = Tspex.request!(contract)
{:ok, answer} = Tspex.HTTP.post("https://tsa.example.com/tsp", request.der)
{:ok, stamp} = Tspex.verify(answer, request, trust: [tsa_root])Keep the Tspex.Request: the nonce in it is what ties the answer to this
request.
Looking at a token
Tspex.decode_token/2 reads a token without judging it: what the TSA said,
not yet what it proves.
{:ok, info} = Tspex.decode_token(token)
{info.gen_time, info.policy, info.serial, info.accuracy}Time-stamping a signature
A signature made with cmsex becomes a long-lived one by time-stamping its
signature value (RFC 3161, appendix A; the id-aa-signatureTimeStampToken
attribute of CAdES-T). The stamp goes in an unsigned attribute, so the
signature does not change.
signer = %{certificate: File.read!("signer.crt"), key: File.read!("signer.key")}
{:ok, cms} = Cmsex.sign(contract, signer)
{:ok, imprint} = Tspex.signature_imprint(cms, 0)
{:ok, stamp} = Tspex.stamp(imprint, url: "https://tsa.example.com/tsp", trust: [tsa_root])
{:ok, cms} = Tspex.add_signature_time_stamp(cms, 0, stamp)Tspex.verify_signature_at_time_stamp/2 then verifies the signature as it
was at the time of the stamp: the stamp is verified, and the signature and
the certificate path of the signer are checked at that time. The certificate
of the signer may have expired since, and the signature is still good.
signer_root = File.read!("signer-root.crt")
{:ok, [result]} =
Tspex.verify_signature_at_time_stamp(cms, trust: [signer_root], tsa_trust: [tsa_root])
result.timeA signer with no verified stamp is :no_time_stamp; it never falls back to the
current date.
A content time-stamp (RFC 5126, 5.11.4) goes the other way: the content is stamped first, and the token is a signed attribute.
{:ok, stamp} = Tspex.stamp(contract, url: "https://tsa.example.com/tsp", trust: [tsa_root])
attribute = Tspex.content_time_stamp_attribute(stamp)
{:ok, cms} = Cmsex.sign(contract, Map.put(signer, :signed_attributes, [attribute]))
{:ok, verified} = Cmsex.verify(cms)
{:ok, [_stamp]} = Tspex.verify_content_time_stamp(verified, 0, trust: [tsa_root])A document with a chain of time-stamps
Tspex.TimeStampedData (RFC 5544) keeps a document and its time-stamps in
one file. Each time-stamp covers the one before it, so a new one, from the same
TSA or from another, can be added later, when an algorithm or a key is about to
age out. This part is experimental: RFC 5544 leaves open which octets the
later time-stamps cover, and the choice made here is in the module
documentation.
alias Tspex.TimeStampedData
{:ok, imprint} = TimeStampedData.imprint(contract, file_name: "contract.pdf", hash_protected: true)
{:ok, first} = Tspex.stamp(imprint, url: "https://tsa.example.com/tsp", trust: [tsa_root])
{:ok, tsd} = TimeStampedData.new(contract, first, file_name: "contract.pdf", hash_protected: true)
# later: a second time-stamp, over the first element
{:ok, next} = TimeStampedData.extension_imprint(tsd)
{:ok, second} = Tspex.stamp(next, url: "https://tsa.example.com/tsp", trust: [tsa_root])
{:ok, tsd} = TimeStampedData.extend(tsd, second)
File.write!("contract.pdf.tsd", TimeStampedData.encode(tsd))
{:ok, result} = TimeStampedData.verify(File.read!("contract.pdf.tsd"), trust: [tsa_root])
result.timeresult.time is the time of the first time-stamp. Every token is verified
like Tspex.verify/3 does, each against the certificate of its own TSA as of
its own genTime. The dataUri of a structure that does not carry the
document is never fetched: give the document as :content.
Options worth knowing
:policies- the TSA policies you accept;:policyin the request asks for one.:max_skew- seconds thegenTimemay differ from the local clock.:revocation-fun.(path, gen_time), called when everything else passed; the guide has a recipe that builds it from CRLs.:allow_sha1- SHA-1 imprints and signatures are refused by default; some TSAs still sign with SHA-1.:tsa_nameand:tsa_eku-:strictby default; seeTspex.verify/3.:max_size,:max_depth,:max_nodes- limits for what is read.
Errors are {:error, %Tspex.Error{}} with a stable :reason atom. A bad
argument raises ArgumentError, without showing your data. verify!/3,
stamp!/2, request!/2 and the decode_*! functions raise Tspex.Error
instead of returning it.
Versions
Until 1.0 a minor version can change the API, and the CHANGELOG says what.
From 1.0 on, a new field in Tspex.Verified, Tspex.TSTInfo,
Tspex.Request or Tspex.Response, and a new :reason in Tspex.Error, are
minor changes. Read the fields you need instead of matching a whole struct, and
keep a clause for a reason you do not know.
What it checks against
The requests and answers are checked against openssl ts (OpenSSL 1.1.1 and
3.x, and LibreSSL) both ways: tokens issued by an OpenSSL TSA verify here, and
what this library writes is read by OpenSSL. The Java jarsigner asks a test
TSA for the time-stamp of its signature, and this library reads the request
and the stamp; jarsigner -verify accepts a jar whose signature this library
time-stamped. The negative cases (changed tokens, wrong imprints and nonces, certificates that
are not for time-stamping, chains that do not lead to a root) are in the tests,
along with fuzzing of every reader.
It has also been tried by hand against the time-stamping services that
several certification authorities run in public: tokens signed with RSA
(2048 to 4096 bits) and ECDSA keys, with SHA-256, SHA-384 and SHA-512, and
carrying ESSCertID, ESSCertIDv2 or both, verified fresh and as stored
tokens. One of them signs with SHA-1, so it needs allow_sha1: true. That
check needs the network and is not part of the tests.
Security
Read SECURITY.md for the threat model and for how to report a vulnerability.
License
Apache-2.0: see the LICENSE and NOTICE files.