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. It also puts time-stamps in
signatures made with Cmsex.
Ask and verify
{:ok, stamp} =
Tspex.stamp(File.read!("contract.pdf"),
url: "https://tsa.example.com/tsp",
trust: [File.read!("tsa-root.crt")]
)
stamp.info.gen_time
File.write!("contract.pdf.tst", stamp.token)Later, with the document and the token:
{:ok, stamp} =
Tspex.verify(File.read!("contract.pdf.tst"), {:data, File.read!("contract.pdf")},
trust: [File.read!("tsa-root.crt")]
)The functions, by what they do:
request/2,decode_request/2,decode_response/2anddecode_token/2build and read the messages, without judging them.verify/3judges a response or a token;stamp/2asks and verifies in one call. Read whatverify/3promises, and what it does not, before relying on a result.signature_imprint/3,add_signature_time_stamp/4,signature_time_stamps/3,verify_signature_time_stamps/3andverify_signature_at_time_stamp/2time-stamp a signature of a CMS message.content_time_stamp_attribute/1andverify_content_time_stamp/3do the same for the content.path_validation_at/2gives the options to validate a certificate path at a date.Tspex.TimeStampedData(experimental) keeps a document with a chain of time-stamps, RFC 5544.
A function that can fail returns {:ok, value} or {:error, %Tspex.Error{}}.
request!/2, decode_request!/2, decode_response!/2, decode_token!/2,
verify!/3 and stamp!/2 raise the error instead. A bad argument raises
ArgumentError, without showing your data.
Summary
Types
What a time-stamp is about: the data as a binary, {:stream, enumerable}
(read once) or {:digest, hash, digest} (already computed).
An extension: the OID, whether it is critical and the DER of its value.
The name of a TSA in the tsa field of a token, as a GeneralName.
A hash algorithm for a message imprint.
Functions
Adds a time-stamp to the signer at position as an unsigned attribute
(id-aa-timeStampToken), and returns the new message. The signature does
not change, since the attribute is not signed, and the signer may have
several stamps, from different TSAs.
The attribute for a content time-stamp (id-aa-ets-contentTimestamp,
RFC 5126, 5.11.4), from a Tspex.Verified whose imprint is the hash of the
content that is to be signed. It is a signed attribute, so the token has
to exist before the signature
Reads a TimeStampReq, for a test server or for inspection. Takes the
limits :max_size, :max_depth and :max_nodes.
Like decode_request/2, but raises Tspex.Error.
Reads a TimeStampResp (RFC 3161, 2.4.2) without judging it: a rejection
is a valid response, with status: :rejection, the reasons in
fail_info and no token. verify/3 is what turns a response that is not
granted into an error. Takes the limits :max_size, :max_depth and
:max_nodes.
Like decode_response/2, but raises Tspex.Error.
Reads a time-stamp token (the ContentInfo of RFC 3161, 2.4.2) down to
its TSTInfo, without verifying anything: not the signature, not the
certificate of the TSA, not the match with a request. Use it to look at a
token; use verify/3 before relying on what it says.
Like decode_token/2, but raises Tspex.Error.
The options for the :path_validation of Cmsex.verify/2 (and of the
:path_validation option of verify/3) that make the OTP validate a
certificate path at time instead of now.
Builds a time-stamp request (TimeStampReq, RFC 3161, 2.4.1) for data.
Like request/2, but returns the request itself.
The time-stamps of the signer at position, read and not verified: the
TSTInfo of each token of each id-aa-timeStampToken attribute, in the
order of the message. A signer with none gives an empty list. Takes the
limits of signature_imprint/3.
Asks a TSA for a time-stamp of data and verifies the answer: request/2,
the transport, decode_response/2 and verify/3 in one call.
Like stamp/2, but raises Tspex.Error.
Verifies a time-stamp response or token and returns what it proves.
Like verify/3, but raises Tspex.Error.
Verifies the content time-stamp of the signer with index index of a
message that Cmsex.verify/2 has verified. The imprint has to be the hash
of the content that the signature covers; for a detached message give
content: (data, as in request/2), for one that carries its content
the content is taken from verified.
Verifies a signature as it was at the time of its time-stamp, which is
how a signature outlives the certificate of its signer: for each signer,
the signature time-stamps are verified, the earliest genTime among them
is taken, and the signature and the certificate path of the signer are
verified at that time.
Verifies the time-stamps of the signer at position against its
signature value, with the options of verify/3 (:trust is required and
names the roots of the TSAs) and
Types
@type data() :: binary() | {:stream, Enumerable.t()} | {:digest, hash(), binary()}
What a time-stamp is about: the data as a binary, {:stream, enumerable}
(read once) or {:digest, hash, digest} (already computed).
An extension: the OID, whether it is critical and the DER of its value.
@type general_name() :: {:directory_name, binary()} | {:dns_name, String.t()} | {:uri, String.t()} | {:rfc822_name, String.t()} | {:ip_address, binary()} | {:other, binary()}
The name of a TSA in the tsa field of a token, as a GeneralName.
@type hash() :: :sha1 | :sha224 | :sha256 | :sha384 | :sha512
A hash algorithm for a message imprint.
Functions
@spec add_signature_time_stamp( binary(), Cmsex.position(), Tspex.Verified.t() | binary(), keyword() ) :: {:ok, binary()} | {:error, Tspex.Error.t()}
Adds a time-stamp to the signer at position as an unsigned attribute
(id-aa-timeStampToken), and returns the new message. The signature does
not change, since the attribute is not signed, and the signer may have
several stamps, from different TSAs.
stamp is a Tspex.Verified.t/0 or a token in DER. The imprint has to
be the hash of that signer's signature (:imprint_mismatch otherwise:
a stamp of another signature must not pass). A token in DER is verified
first with opts, which are those of verify/3 and need :trust; a
Tspex.Verified is taken to have been verified already.
@spec content_time_stamp_attribute(Tspex.Verified.t()) :: Cmsex.Attribute.t()
The attribute for a content time-stamp (id-aa-ets-contentTimestamp,
RFC 5126, 5.11.4), from a Tspex.Verified whose imprint is the hash of the
content that is to be signed. It is a signed attribute, so the token has
to exist before the signature:
{:ok, verified} = Tspex.verify(response, request, trust: tsa_roots)
attribute = Tspex.content_time_stamp_attribute(verified)
signer = %{certificate: cert, key: key, signed_attributes: [attribute]}
{:ok, cms} = Cmsex.sign(content, signer)
@spec decode_request(binary(), keyword()) :: {:ok, Tspex.Request.t()} | {:error, Tspex.Error.t()}
Reads a TimeStampReq, for a test server or for inspection. Takes the
limits :max_size, :max_depth and :max_nodes.
A request with a hash this library does not know is read, with the OID as
the :hash.
iex> request = Tspex.request!("hello", nonce: 42)
iex> {:ok, read} = Tspex.decode_request(request.der)
iex> read == request
true
@spec decode_request!(binary(), keyword()) :: Tspex.Request.t()
Like decode_request/2, but raises Tspex.Error.
@spec decode_response(binary(), keyword()) :: {:ok, Tspex.Response.t()} | {:error, Tspex.Error.t()}
Reads a TimeStampResp (RFC 3161, 2.4.2) without judging it: a rejection
is a valid response, with status: :rejection, the reasons in
fail_info and no token. verify/3 is what turns a response that is not
granted into an error. Takes the limits :max_size, :max_depth and
:max_nodes.
{:ok, response} = Tspex.decode_response(body)
response.status #=> :rejection
response.fail_info #=> [:bad_alg]
response.status_text #=> ["Message digest algorithm is not supported."]A PKIStatus outside 0 to 5 is :unknown_status. A failInfo bit that
RFC 3161 does not define is read as {:unknown_bit, n}.
A rejection that says only that the algorithm is not supported is eleven bytes:
iex> der = <<0x30, 0x09, 0x30, 0x07, 0x02, 0x01, 0x02, 0x03, 0x02, 0x07, 0x80>>
iex> {:ok, response} = Tspex.decode_response(der)
iex> {response.status, response.fail_info, response.token}
{:rejection, [:bad_alg], nil}
@spec decode_response!(binary(), keyword()) :: Tspex.Response.t()
Like decode_response/2, but raises Tspex.Error.
@spec decode_token(binary(), keyword()) :: {:ok, Tspex.TSTInfo.t()} | {:error, Tspex.Error.t()}
Reads a time-stamp token (the ContentInfo of RFC 3161, 2.4.2) down to
its TSTInfo, without verifying anything: not the signature, not the
certificate of the TSA, not the match with a request. Use it to look at a
token; use verify/3 before relying on what it says.
Takes the limits :max_size (1 MiB), :max_depth, :max_nodes,
:max_signers, :max_certificates and :max_attributes.
:not_a_time_stamp_token when the ContentInfo is not a SignedData or
the signed content is not a TSTInfo.
@spec decode_token!(binary(), keyword()) :: Tspex.TSTInfo.t()
Like decode_token/2, but raises Tspex.Error.
@spec path_validation_at(DateTime.t(), keyword()) :: keyword()
The options for the :path_validation of Cmsex.verify/2 (and of the
:path_validation option of verify/3) that make the OTP validate a
certificate path at time instead of now.
:public_key.pkix_path_validation/3 has no date option and judges a
certificate by the clock of today, so a token made in 2024 stops
validating when the certificate of the TSA expires, and a critical
extKeyUsage of a TSA never validates. The :verify_fun that comes with
the options decides the validity of every certificate of the path, the
root included, by time, and lets an extKeyUsage through if it holds
id-kp-timeStamping or anyExtendedKeyUsage; the OTP does the rest of
RFC 5280, 6.1. Your own :verify_fun is refused (ArgumentError): check
revocation with the :revocation option of verify/3.
Notes that matter:
- Validating at a date is not checking revocation at that date.
- The root is held to the same rule as the others: one that had expired
at
timeis refused, one that expires later is accepted. RFC 5280, 6.1 does not look at the validity of a trust anchor, so this is stricter. - A CA whose
extKeyUsagelackstimeStampingandanyExtendedKeyUsageis refused (:eku_excludes_time_stamping). - A CA with an
extKeyUsageand nokeyUsagemakes the OTP raise on some versions;Cmsex.verify/2reports it as:untrusted.
The result is a keyword list for :public_key.pkix_path_validation/3:
iex> options = Tspex.path_validation_at(~U[2024-01-01 00:00:00Z])
iex> Keyword.keys(options)
[:verify_fun]
@spec request(data(), keyword()) :: {:ok, Tspex.Request.t()}
Builds a time-stamp request (TimeStampReq, RFC 3161, 2.4.1) for data.
data is a binary, {:stream, enumerable} (read once) or
{:digest, hash, digest} (already computed).
{:ok, request} = Tspex.request(File.read!("contract.pdf"))
request.der # the body of the POST to the TSA
request.nonce # keep the struct: verify/3 needs itOptions:
:hash-:sha256(the default),:sha224,:sha384,:sha512;:sha1only withallow_sha1: true. With{:digest, hash, _}the algorithm is given, and a different:hashraisesArgumentError.:policy- the OID of the policy asked for, as a tuple or as text ("1.2.3.4.5.6"); none by default.:nonce-true(the default) makes a random 63-bit nonce,falseleaves it out, a positive integer uses that value.:cert_req- ask for the certificate of the TSA in the token (true).:extensions-[%{oid: tuple, critical: boolean, value: der}].:allow_sha1-false.
The hash algorithm is written with NULL parameters, as the openssl ts
tool and the Java jarsigner do. A bad argument raises ArgumentError
without showing the data.
Building a request cannot fail in any other way, so this always returns
{:ok, request}; it has the same shape as the other functions so that code
can treat them alike. request!/2 is the same without the tuple.
@spec request!(data(), keyword()) :: Tspex.Request.t()
Like request/2, but returns the request itself.
iex> request = Tspex.request!("hello", nonce: 42)
iex> {request.hash, request.nonce, request.cert_req}
{:sha256, 42, true}
iex> Base.encode16(request.digest, case: :lower)
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
@spec signature_imprint(binary(), Cmsex.position(), keyword()) :: {:ok, {:digest, hash(), binary()}} | {:error, Tspex.Error.t()}
The imprint to time-stamp a signature: the hash of the signature value of
the signer at position (RFC 3161, appendix A), as {:digest, hash, digest}, which request/2 and verify/3 take.
position is the one of Cmsex: an integer for a signer, a list such as
[0, 0] for the first countersignature of the first signer.
{:ok, cms} = Cmsex.sign(content, signer)
{:ok, imprint} = Tspex.signature_imprint(cms, 0)
request = Tspex.request!(imprint)Options: :hash, :sha256 (the default), :sha224, :sha384 or
:sha512; and the limits :max_size, :max_depth, :max_nodes,
:max_signers, :max_certificates and :max_attributes.
@spec signature_time_stamps(binary(), Cmsex.position(), keyword()) :: {:ok, [Tspex.TSTInfo.t()]} | {:error, Tspex.Error.t()}
The time-stamps of the signer at position, read and not verified: the
TSTInfo of each token of each id-aa-timeStampToken attribute, in the
order of the message. A signer with none gives an empty list. Takes the
limits of signature_imprint/3.
@spec stamp(data(), keyword()) :: {:ok, Tspex.Verified.t()} | {:error, Tspex.Error.t()}
Asks a TSA for a time-stamp of data and verifies the answer: request/2,
the transport, decode_response/2 and verify/3 in one call.
{:ok, verified} =
Tspex.stamp(File.read!("contract.pdf"),
url: "https://tsa.example.com/tsp",
trust: [root_der],
policy: "1.2.3.4.5.6"
)
File.write!("contract.pdf.tst", verified.token)Takes the options of request/2 and of verify/3 (:trust is
required) and these:
:url- the address of the TSA; required unless there is a:transport.:transport- a functionfun.(url, request_der, options)that sends the request and returns{:ok, response_der}or{:error, reason}. The default isTspex.HTTP.post/3. Use your own for a proxy, an HTTP client you already have (Req, Finch, Mint) or a test.optionsis a keyword list with:headers,:timeout,:max_redirects,:max_response_bytesand:ssl; areasonthat is not aTspex.Errorbecomes thedetailof a:transporterror.:headers,:timeout(30 s for the exchange),:max_redirects(0),:max_response_bytes(1 MiB) and:ssl- seeTspex.HTTP.:input-:responsehere, because that is what a TSA answers.
The error of a TSA that refused is a :rejection (or :waiting and the
like) with the failInfo in the detail; one of the network is a
:transport error. Nothing is retried.
http URLs work, with the hash of your data in the clear on the way: see
Tspex.HTTP.
@spec stamp!(data(), keyword()) :: Tspex.Verified.t()
Like stamp/2, but raises Tspex.Error.
@spec verify(binary(), Tspex.Request.t() | tuple(), keyword()) :: {:ok, Tspex.Verified.t()} | {:error, Tspex.Error.t()}
Verifies a time-stamp response or token and returns what it proves.
input is the whole TimeStampResp (what the TSA sent) or the token
alone (the ContentInfo, as kept in a file or an attribute). The second
argument says what was time-stamped:
- a
Tspex.Request(fresh): the response to that request. The nonce and the policy of the request are checked, and so is its imprint. {:data, binary},{:stream, enumerable}or{:digest, hash, digest}(stored): a token kept for later. The data is hashed with the algorithm the token itself declares, if it is allowed. There is no nonce to compare, and a nonce in the token is only information.
What {:ok, verified} promises
The token is a SignedData with one signer who signed the TSTInfo; the
certificate that signed is the one the signing-certificate attribute
names and is a certificate of a time-stamping authority (a critical
extKeyUsage with id-kp-timeStamping); its chain leads to one of the
:trust roots, judged at the genTime and not today, so a token stays
valid after the certificate of the TSA expires; the imprint is that of the
data, and in fresh mode the nonce is that of the request.
What it does not promise
- That the TSA is honest or its clock right:
:trustis your decision andgenTimeis the word of the TSA. - That the certificate of the TSA was not revoked: without
:revocationthe result saysrevocation: :not_checked. - That the stamp will keep proving anything for ever: keys and algorithms age.
- That the stamped signature or document is valid.
Options
:trust- required: a list of root certificates in DER, or:none, which gives up checking the chain: the result then saystrust: :noneandsigner.pathisnil, and the token proves only that some key signed this imprint.:certificates- certificates in DER to find the TSA and the intermediates in, tried before those of the token (needed when the request hadcert_req: false).:input-:auto(the default),:responseor:token.:policies- the TSA policies you accept (OIDs as tuples or text); none accepts any. A policy in the request must always be the one of the token.:tsa_name-:strict(the default) checks thetsafield against the names of the certificate;:ignoredoes not.:tsa_eku-:strict(the default): theextKeyUsageis critical, written once, and holds only timeStamping;:extra_purposesallows other purposes next to it.:critical_extensions-:reject(the default) or:ignorean unknown critical extension of theTSTInfo.:granted_with_mods-:accept(the default) or:rejectstatus 1.:max_skew- seconds of difference allowed between thegenTimeand the local clock, plus the accuracy;nil(the default) does not look at the clock.:nowreplaces the clock, for tests.:revocation-fun.(path, gen_time)returning:okor{:error, reason}, called with the validated path (root first, TSA last, in DER) after everything else passed. An error is:untrustedwith{:revocation, reason}. RFC 3161, section 4: a revocation forkeyCompromiseor without a reason invalidates every token of the key; any other reason only those made after the revocation. The guide "What a verified time-stamp means" has a recipe that builds this function from CRLs.:allow_sha1(false),:min_rsa_bits(2048) - as inCmsex; MD5 is never accepted.:path_validation- options for:public_key.pkix_path_validation/3, except:verify_fun.:max_size(1 MiB),:max_depth,:max_nodesand the limits ofCmsex.decode/2.
The order of the checks
Size and form; status and failInfo; the token; SignedData and the type
of its content; one signer; the version of the TSTInfo; the signature
(:token_invalid); the signing-certificate attribute; the rules for the
certificate of a TSA (:bad_tsa_certificate); the chain to the roots at
the genTime (:untrusted); the imprint; the nonce; the policy; the tsa
name; critical extensions; the clock; :revocation. The error is the
first one that fails, and the rules for the certificate come before the
chain so that a certificate that is not for time-stamping is reported as
such and not as an error of the OTP.
Errors are {:error, %Tspex.Error{}}; a bad argument raises
ArgumentError without showing the data. Bytes that are not a token are an
error value, not an exception:
iex> {:error, error} = Tspex.verify("not a token", {:data, "x"}, trust: :none)
iex> error.reason
:malformed
@spec verify!(binary(), Tspex.Request.t() | tuple(), keyword()) :: Tspex.Verified.t()
Like verify/3, but raises Tspex.Error.
@spec verify_content_time_stamp(Cmsex.Verified.t(), non_neg_integer(), keyword()) :: {:ok, [Tspex.Verified.t()]} | {:error, Tspex.Error.t()}
Verifies the content time-stamp of the signer with index index of a
message that Cmsex.verify/2 has verified. The imprint has to be the hash
of the content that the signature covers; for a detached message give
content: (data, as in request/2), for one that carries its content
the content is taken from verified.
Takes the options of verify/3 (:trust is required) and :stamps, as
verify_signature_time_stamps/3. A signer with no such attribute is
:no_time_stamp.
@spec verify_signature_at_time_stamp(binary(), keyword()) :: {:ok, [map()]} | {:error, Tspex.Error.t()}
Verifies a signature as it was at the time of its time-stamp, which is
how a signature outlives the certificate of its signer: for each signer,
the signature time-stamps are verified, the earliest genTime among them
is taken, and the signature and the certificate path of the signer are
verified at that time.
Returns {:ok, [%{signer: signer, stamps: [verified], time: time}]}, one
entry per signer, where signer is as in Cmsex.Verified and time the
earliest genTime. A signer without a verified time-stamp is
:no_time_stamp: this never falls back to today's date (use
Cmsex.verify/2 for that).
Options:
:trust(required) - the roots of the signer's certificates.:tsa_trust- the roots of the TSAs (the value of:trust).:stamps-:allor:any, as inverify_signature_time_stamps/3.:revocation-fn path, time -> :ok | {:error, reason} end, called with the path of the signer and the time of the stamp.:content,:certificates,:allow_sha1,:min_rsa_bits,:path_validation- for the signature, as inCmsex.verify/2.:policies,:tsa_name,:tsa_eku,:critical_extensions- for the stamps, as inverify/3.- the limits
:max_size,:max_depth,:max_nodes,:max_signers,:max_certificatesand:max_attributes.
What this proves is that the signature is older than the time-stamp, and
that the certificate was valid then. It does not say that the signer was
authorized to sign: the signature policy of a profile such as CAdES is not
checked here. Validating at a date is not checking revocation at that date
(see path_validation_at/2).
To check only the time-stamps of one signer, and not the signature or its
certificate, use verify_signature_time_stamps/3.
@spec verify_signature_time_stamps(binary(), Cmsex.position(), keyword()) :: {:ok, [Tspex.Verified.t()]} | {:error, Tspex.Error.t()}
Verifies the time-stamps of the signer at position against its
signature value, with the options of verify/3 (:trust is required and
names the roots of the TSAs) and:
:stamps-:all(the default) fails with the first stamp that does not verify;:anyreturns those that did, and fails with the first error only when none did.
No time-stamp is :no_time_stamp.
This says when the signature existed. It does not check the signature
itself or the certificate of the signer: see verify_signature_at_time_stamp/2.