Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion internal/tsa/doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
// Package tsa is the node's RFC 3161 time-stamp authority: the TSA
// certificate and key it signs tokens with.
// certificate and key it signs tokens with, and the responder that turns a
// TimeStampReq into a TimeStampResp.
//
// # Tokens
//
// Responder accepts version 1 requests with a SHA-256, SHA-384 or SHA-512
// message imprint; SHA-1, MD5 and anything else get badAlg. A request for a
// policy other than the configured one gets unacceptedPolicy, and a request
// with extensions gets unacceptedExtension, since none is supported. A
// granted token echoes the message imprint and the nonce, names the
// configured policy, carries a random 159-bit serial number, a genTime in
// UTC to the millisecond and the configured accuracy, and claims no ordering.
// It is a SignedData built by internal/cms, signed by the TSA key with the
// digest the node pairs with that key (SHA-384 for P-384 and RSA 3072 or
// larger), with a signing-certificate-v2 attribute (RFC 5816) naming the TSA
// certificate by SHA-256 hash, issuer and serial number. The TSA certificate
// is carried only when the request asks for it (certReq).
//
// # Keys and certificates
//
Expand Down
183 changes: 183 additions & 0 deletions internal/tsa/openssl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package tsa

/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// The OpenSSL tests check the tokens against an independent RFC 3161
// implementation: `openssl ts` builds the requests and verifies the replies.
// They need OpenSSL 3 (CRYPTOS_TEST_OPENSSL overrides the binary). Without
// it they skip locally, but fail under CI, so the check never silently stops
// running.

type tsOpenSSL struct {
t *testing.T
bin string
dir string
}

func newTSOpenSSL(t *testing.T) *tsOpenSSL {
t.Helper()
bin := os.Getenv("CRYPTOS_TEST_OPENSSL")
if bin == "" {
bin = "openssl"
}
unavailable := func(why string) {
if os.Getenv("CI") != "" {
t.Fatalf("OpenSSL interoperability tests cannot run under CI: %s", why)
}
t.Skipf("skipping OpenSSL interoperability: %s (set CRYPTOS_TEST_OPENSSL)", why)
}
path, err := exec.LookPath(bin)
if err != nil {
unavailable(bin + " is not on PATH")
}
out, err := exec.Command(path, "version").CombinedOutput()
if err != nil || !strings.HasPrefix(string(out), "OpenSSL 3") {
unavailable("need OpenSSL 3, have " + strings.TrimSpace(string(out)))
}
return &tsOpenSSL{t: t, bin: path, dir: t.TempDir()}
}

func (o *tsOpenSSL) run(args ...string) string {
o.t.Helper()
cmd := exec.Command(o.bin, args...)
cmd.Dir = o.dir
out, err := cmd.CombinedOutput()
if err != nil {
o.t.Fatalf("openssl %s: %v\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}

func (o *tsOpenSSL) write(name string, data []byte) string {
o.t.Helper()
p := filepath.Join(o.dir, name)
if err := os.WriteFile(p, data, 0o600); err != nil {
o.t.Fatal(err)
}
return p
}

func (o *tsOpenSSL) read(name string) []byte {
o.t.Helper()
b, err := os.ReadFile(filepath.Join(o.dir, name))
if err != nil {
o.t.Fatal(err)
}
return b
}

func certPEM(c *x509.Certificate) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw})
}

// tsaKeys are the TSA key algorithms a node can have: the CA key's.
func tsaKeys(t *testing.T) map[string]crypto.Signer {
t.Helper()
ec, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
r, err := rsa.GenerateKey(rand.Reader, 3072)
if err != nil {
t.Fatal(err)
}
return map[string]crypto.Signer{"ECDSA P-384": ec, "RSA 3072": r}
}

func TestOpenSSLVerifiesOurTokens(t *testing.T) {
o := newTSOpenSSL(t)
for name, key := range tsaKeys(t) {
t.Run(name, func(t *testing.T) {
o := &tsOpenSSL{t: t, bin: o.bin, dir: t.TempDir()}
authority := newTestCA(t, "Example Issuing CA G1")
s := &staticSigner{cert: issueTSACert(t, authority, key), key: key}
r := newTestResponder(t, s, nil)
o.write("ca.pem", certPEM(authority.cert))
o.write("tsa.pem", certPEM(s.cert))
o.write("artifact.bin", []byte("an artifact to timestamp"))

cases := []struct {
name string
query []string
// verify is how the reply is checked: against the query
// file, or against the data with the TSA certificate
// supplied when the token does not carry it.
verify []string
}{
{"sha256 with nonce and certReq", []string{"-sha256", "-cert"}, []string{"-queryfile", "req.tsq"}},
{"sha384 without certReq", []string{"-sha384"}, []string{"-data", "artifact.bin", "-untrusted", "tsa.pem"}},
{"sha512 no nonce with the served policy", []string{"-sha512", "-no_nonce", "-cert", "-tspolicy", testPolicy.String()}, []string{"-queryfile", "req.tsq"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
o := &tsOpenSSL{t: t, bin: o.bin, dir: o.dir}
o.run(append([]string{"ts", "-query", "-data", "artifact.bin", "-out", "req.tsq"}, tc.query...)...)
resp, out, err := r.Respond(context.Background(), o.read("req.tsq"))
if err != nil || !out.Granted {
t.Fatalf("Respond: granted=%t err=%v reason=%s", out.Granted, err, out.Reason)
}
o.write("resp.tsr", resp)
got := o.run(append([]string{"ts", "-verify", "-in", "resp.tsr", "-CAfile", "ca.pem"}, tc.verify...)...)
if !strings.Contains(got, "Verification: OK") {
t.Fatalf("openssl ts -verify did not report OK:\n%s", got)
}
text := o.run("ts", "-reply", "-in", "resp.tsr", "-text")
for _, want := range []string{"Status: Granted.", "Policy OID: " + testPolicy.String(), "Accuracy: 0x01 seconds, 0x01F4 millis, unspecified micros", "Ordering: no"} {
if !strings.Contains(text, want) {
t.Errorf("openssl ts -reply -text lacks %q:\n%s", want, text)
}
}
})
}
})
}
}

func TestOpenSSLReadsOurRejection(t *testing.T) {
o := newTSOpenSSL(t)
_, s := newTSA(t)
r := newTestResponder(t, s, nil)
o.write("artifact.bin", []byte("an artifact to timestamp"))
o.run("ts", "-query", "-data", "artifact.bin", "-sha1", "-out", "req.tsq")
resp, out, err := r.Respond(context.Background(), o.read("req.tsq"))
if err != nil || out.Granted || out.Fail != FailBadAlg {
t.Fatalf("Respond to a SHA-1 query: granted=%t fail=%s err=%v", out.Granted, out.Fail, err)
}
o.write("resp.tsr", resp)
text := o.run("ts", "-reply", "-in", "resp.tsr", "-text")
for _, want := range []string{"Status: Rejected.", "unrecognized or unsupported algorithm identifier"} {
if !strings.Contains(text, want) {
t.Errorf("openssl ts -reply -text lacks %q:\n%s", want, text)
}
}
}
190 changes: 190 additions & 0 deletions internal/tsa/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package tsa

/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import (
"bytes"
"crypto"
"crypto/x509/pkix"
"encoding/asn1"
"fmt"
"math/big"
)

// FailureInfo is a PKIFailureInfo bit (RFC 3161 section 2.4.2).
type FailureInfo int

// The PKIFailureInfo bits a TSA answers with.
const (
// FailBadAlg is an unrecognized or unsupported message imprint
// algorithm.
FailBadAlg FailureInfo = 0
// FailBadRequest is a transaction not permitted or supported.
FailBadRequest FailureInfo = 2
// FailBadDataFormat is a request with the wrong format.
FailBadDataFormat FailureInfo = 5
// FailTimeNotAvailable means the TSA's time source is not available.
FailTimeNotAvailable FailureInfo = 14
// FailUnacceptedPolicy is a requested policy the TSA does not support.
FailUnacceptedPolicy FailureInfo = 15
// FailUnacceptedExtension is a requested extension the TSA does not
// support.
FailUnacceptedExtension FailureInfo = 16
// FailSystemFailure is a request that cannot be handled because of a
// system failure.
FailSystemFailure FailureInfo = 25
)

func (f FailureInfo) String() string {
switch f {
case FailBadAlg:
return "badAlg"
case FailBadRequest:
return "badRequest"
case FailBadDataFormat:
return "badDataFormat"
case FailTimeNotAvailable:
return "timeNotAvailable"
case FailUnacceptedPolicy:
return "unacceptedPolicy"
case FailUnacceptedExtension:
return "unacceptedExtension"
case FailSystemFailure:
return "systemFailure"
default:
return fmt.Sprintf("failInfo(%d)", int(f))
}
}

// RequestError is a request the TSA refuses, with the failure bit it answers
// with and a reason for the log.
type RequestError struct {
Fail FailureInfo
Reason string
}

func (e *RequestError) Error() string {
return fmt.Sprintf("tsa: %s: %s", e.Fail, e.Reason)
}

func refuse(f FailureInfo, format string, args ...any) *RequestError {
return &RequestError{Fail: f, Reason: fmt.Sprintf(format, args...)}
}

// Request is a parsed TimeStampReq (RFC 3161 section 2.4.1).
type Request struct {
// Hash is the message imprint's algorithm: SHA-256, SHA-384 or SHA-512.
Hash crypto.Hash
// HashedMessage is the message imprint's hash value.
HashedMessage []byte
// ReqPolicy is the policy the requester asks for, or nil.
ReqPolicy asn1.ObjectIdentifier
// Nonce is the requester's nonce, or nil when it sent none.
Nonce *big.Int
// CertReq asks for the TSA certificate in the token.
CertReq bool

// rawImprint is the MessageImprint exactly as received; the token
// echoes it unchanged.
rawImprint []byte
}

type rawTimeStampReq struct {
Version int
MessageImprint asn1.RawValue
ReqPolicy asn1.ObjectIdentifier `asn1:"optional"`
Nonce *big.Int `asn1:"optional"`
CertReq bool `asn1:"optional,default:false"`
Extensions rawTagged `asn1:"optional,tag:0"`
}

// rawTagged holds an optional implicitly tagged field. A bare RawValue would
// not have its tag checked by encoding/asn1 and would swallow any element.
type rawTagged struct {
Raw asn1.RawContent
}

type rawMessageImprint struct {
HashAlgorithm pkix.AlgorithmIdentifier
HashedMessage []byte
}

// imprintHashes are the accepted message imprint algorithms. SHA-1, MD5 and
// everything else are refused with badAlg.
var imprintHashes = []struct {
oid asn1.ObjectIdentifier
hash crypto.Hash
}{
{asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, crypto.SHA256},
{asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 2}, crypto.SHA384},
{asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3}, crypto.SHA512},
}

// ParseRequest parses a DER TimeStampReq. A request the TSA refuses comes
// back as a *RequestError naming the failure bit to answer with.
func ParseRequest(der []byte) (*Request, error) {
var raw rawTimeStampReq
rest, err := asn1.Unmarshal(der, &raw)
if err != nil {
return nil, refuse(FailBadDataFormat, "the request is not a DER TimeStampReq: %v", err)
}
if len(rest) != 0 {
return nil, refuse(FailBadDataFormat, "%d trailing bytes after the TimeStampReq", len(rest))
}
if raw.Version != 1 {
return nil, refuse(FailBadDataFormat, "version %d, want 1", raw.Version)
}
if raw.Extensions.Raw != nil {
// Section 2.4.1: an extension the server does not recognize,
// critical or not, gets unacceptedExtension. This TSA recognizes
// none.
return nil, refuse(FailUnacceptedExtension, "the request carries extensions and this TSA supports none")
}

var mi rawMessageImprint
rest, err = asn1.Unmarshal(raw.MessageImprint.FullBytes, &mi)
if err != nil || len(rest) != 0 {
return nil, refuse(FailBadDataFormat, "the messageImprint is not a MessageImprint")
}
var h crypto.Hash
for _, ih := range imprintHashes {
if ih.oid.Equal(mi.HashAlgorithm.Algorithm) {
h = ih.hash
break
}
}
if h == 0 {
return nil, refuse(FailBadAlg, "message imprint algorithm %s is not accepted (SHA-256, SHA-384 or SHA-512)", mi.HashAlgorithm.Algorithm)
}
// RFC 5754 section 2: the parameters are absent, though NULL is
// accepted as many encoders write it.
if p := mi.HashAlgorithm.Parameters.FullBytes; len(p) != 0 && !bytes.Equal(p, asn1.NullBytes) {
return nil, refuse(FailBadAlg, "message imprint algorithm %s carries parameters", mi.HashAlgorithm.Algorithm)
}
if len(mi.HashedMessage) != h.Size() {
return nil, refuse(FailBadDataFormat, "the %s message imprint is %d bytes, want %d", h, len(mi.HashedMessage), h.Size())
}

return &Request{
Hash: h,
HashedMessage: mi.HashedMessage,
ReqPolicy: raw.ReqPolicy,
Nonce: raw.Nonce,
CertReq: raw.CertReq,
rawImprint: raw.MessageImprint.FullBytes,
}, nil
}
Loading
Loading