diff --git a/internal/tsa/doc.go b/internal/tsa/doc.go index 0690359..a515997 100644 --- a/internal/tsa/doc.go +++ b/internal/tsa/doc.go @@ -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 // diff --git a/internal/tsa/openssl_test.go b/internal/tsa/openssl_test.go new file mode 100644 index 0000000..cd343b8 --- /dev/null +++ b/internal/tsa/openssl_test.go @@ -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) + } + } +} diff --git a/internal/tsa/request.go b/internal/tsa/request.go new file mode 100644 index 0000000..d91c675 --- /dev/null +++ b/internal/tsa/request.go @@ -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 +} diff --git a/internal/tsa/responder.go b/internal/tsa/responder.go new file mode 100644 index 0000000..8e7cab5 --- /dev/null +++ b/internal/tsa/responder.go @@ -0,0 +1,143 @@ +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/x509" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "time" +) + +// TokenSigner hands out the TSA certificate and key for one signature. +// *CertManager implements it. +type TokenSigner interface { + WithSigner(ctx context.Context, fn func(cert *x509.Certificate, key crypto.Signer) error) error +} + +// ResponderOptions configures a Responder. +type ResponderOptions struct { + // Policy is the TSA policy every token names. Required. + Policy asn1.ObjectIdentifier + // Accuracy is the accuracy every token claims, at least a millisecond. + Accuracy time.Duration + // Now and Logf default to time.Now and discarding. + Now func() time.Time + Logf func(string, ...any) +} + +// Responder answers RFC 3161 requests: it parses a TimeStampReq and returns +// the DER TimeStampResp, granted with a token or rejected with a failure bit. +type Responder struct { + signer TokenSigner + opts ResponderOptions + serial func() (*big.Int, error) +} + +// NewResponder returns a Responder signing through signer. +func NewResponder(signer TokenSigner, opts ResponderOptions) (*Responder, error) { + if signer == nil { + return nil, errors.New("tsa: NewResponder: a signer is required") + } + if len(opts.Policy) < 2 { + return nil, errors.New("tsa: NewResponder: a policy OID is required") + } + if opts.Accuracy < time.Millisecond { + return nil, fmt.Errorf("tsa: NewResponder: the accuracy (%s) must be at least a millisecond", opts.Accuracy) + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Logf == nil { + opts.Logf = func(string, ...any) {} + } + return &Responder{signer: signer, opts: opts, serial: newSerial}, nil +} + +// Outcome describes how a request was answered, for the caller's log. +type Outcome struct { + Granted bool + // Serial is the token's serial number when granted. + Serial *big.Int + // Fail and Reason say why a request was rejected. + Fail FailureInfo + Reason string +} + +// Respond answers one DER TimeStampReq with a DER TimeStampResp. The reply +// is a TimeStampResp whatever happens; err is set only when not even a +// rejection could be encoded. +func (r *Responder) Respond(ctx context.Context, der []byte) ([]byte, Outcome, error) { + req, err := ParseRequest(der) + if err != nil { + var re *RequestError + if !errors.As(err, &re) { + re = refuse(FailBadDataFormat, "%v", err) + } + return r.reject(re.Fail, re.Reason) + } + if req.ReqPolicy != nil && !req.ReqPolicy.Equal(r.opts.Policy) { + return r.reject(FailUnacceptedPolicy, fmt.Sprintf("the request asks for policy %s; this TSA serves %s", req.ReqPolicy, r.opts.Policy)) + } + + serial, err := r.serial() + if err != nil { + return r.reject(FailSystemFailure, err.Error()) + } + var token []byte + err = r.signer.WithSigner(ctx, func(cert *x509.Certificate, key crypto.Signer) error { + // genTime is read once the key is in hand, as close to the + // signature as possible. + info, err := tstInfo(req, tokenParams{policy: r.opts.Policy, accuracy: r.opts.Accuracy, serial: serial, genTime: r.opts.Now()}) + if err != nil { + return err + } + token, err = signToken(info, cert, key, req.CertReq) + return err + }) + if err != nil { + return r.reject(FailSystemFailure, fmt.Sprintf("signing the token failed: %v", err)) + } + resp, err := grantedResponse(token) + if err != nil { + return r.reject(FailSystemFailure, fmt.Sprintf("encoding the response failed: %v", err)) + } + r.opts.Logf("tsa: granted token %s (%s imprint, nonce=%t, certReq=%t)", serial.Text(16), req.Hash, req.Nonce != nil, req.CertReq) + return resp, Outcome{Granted: true, Serial: serial}, nil +} + +// rejectionText is the statusString a rejection carries: what the failure +// bit means, never internal detail, which goes to the log instead. +var rejectionText = map[FailureInfo]string{ + FailBadAlg: "unsupported message imprint algorithm; use SHA-256, SHA-384 or SHA-512", + FailBadRequest: "request not supported", + FailBadDataFormat: "malformed TimeStampReq", + FailTimeNotAvailable: "the TSA time source is not available", + FailUnacceptedPolicy: "requested policy not served", + FailUnacceptedExtension: "request extensions are not supported", + FailSystemFailure: "system failure", +} + +func (r *Responder) reject(fail FailureInfo, reason string) ([]byte, Outcome, error) { + r.opts.Logf("tsa: rejected with %s: %s", fail, reason) + resp, err := rejectionResponse(fail, rejectionText[fail]) + return resp, Outcome{Fail: fail, Reason: reason}, err +} diff --git a/internal/tsa/responder_test.go b/internal/tsa/responder_test.go new file mode 100644 index 0000000..8a615ff --- /dev/null +++ b/internal/tsa/responder_test.go @@ -0,0 +1,531 @@ +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" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" //nolint:gosec // only to build a SHA-1 imprint the TSA must refuse + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "math/big" + "regexp" + "testing" + "time" + + "github.com/CryptOS-PKI/cryptos-node/internal/ca" + "github.com/CryptOS-PKI/cryptos-node/internal/cms" +) + +var ( + testPolicy = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 32473, 1, 1} + oidSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26} + oidMD5 = asn1.ObjectIdentifier{1, 2, 840, 113549, 2, 5} + oidSHA224 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 4} + oidSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1} + oidSHA384 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 2} + oidSHA512 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3} + oidSHA256NP = pkix.AlgorithmIdentifier{Algorithm: oidSHA256} +) + +// staticSigner is a TokenSigner over one certificate and key. +type staticSigner struct { + cert *x509.Certificate + key crypto.Signer + err error +} + +func (s *staticSigner) WithSigner(_ context.Context, fn func(*x509.Certificate, crypto.Signer) error) error { + if s.err != nil { + return s.err + } + return fn(s.cert, s.key) +} + +// newTSA issues a TSA certificate from a fresh CA with the real profile. +func newTSA(t *testing.T) (*testCA, *staticSigner) { + t.Helper() + authority := newTestCA(t, "Example Issuing CA G1") + key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return authority, &staticSigner{cert: issueTSACert(t, authority, key), key: key} +} + +func issueTSACert(t *testing.T, authority *testCA, key crypto.Signer) *x509.Certificate { + t.Helper() + now := time.Now().UTC() + der, _, err := ca.Sign(CertificateProfile(authority.cert, now, now.Add(testValidity), ""), key.Public(), authority.cert, authority.key) + if err != nil { + t.Fatalf("issue the TSA certificate: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cert +} + +func newTestResponder(t *testing.T, s TokenSigner, now func() time.Time) *Responder { + t.Helper() + r, err := NewResponder(s, ResponderOptions{Policy: testPolicy, Accuracy: 1500 * time.Millisecond, Now: now}) + if err != nil { + t.Fatalf("NewResponder: %v", err) + } + return r +} + +// Wire shapes for reading responses, written from RFC 3161 independently of +// the encoder. +type ( + reqImprint struct { + HashAlgorithm pkix.AlgorithmIdentifier + HashedMessage []byte + } + reqForTest struct { + Version int + MessageImprint reqImprint + ReqPolicy asn1.ObjectIdentifier `asn1:"optional"` + Nonce *big.Int `asn1:"optional"` + CertReq bool `asn1:"optional,default:false"` + } + respStatus struct { + Status int + StatusString []string `asn1:"optional,utf8"` + FailInfo asn1.BitString `asn1:"optional"` + } + respForTest struct { + Status respStatus + Token asn1.RawValue `asn1:"optional"` + } + accuracyForTest struct { + Seconds int `asn1:"optional"` + Millis int `asn1:"optional,tag:0"` + Micros int `asn1:"optional,tag:1"` + } + tstInfoForTest struct { + Version int + Policy asn1.ObjectIdentifier + MessageImprint asn1.RawValue + SerialNumber *big.Int + GenTime asn1.RawValue + Accuracy accuracyForTest `asn1:"optional"` + Ordering bool `asn1:"optional,default:false"` + Nonce *big.Int `asn1:"optional"` + } + issuerSerialForTest struct { + Issuer []asn1.RawValue + Serial *big.Int + } + essCertIDv2ForTest struct { + Raw asn1.RawContent + CertHash []byte + IssuerSerial issuerSerialForTest + } + signingCertV2ForTest struct { + Certs []essCertIDv2ForTest + } +) + +func encodeRequest(t *testing.T, r reqForTest) []byte { + t.Helper() + der, err := asn1.Marshal(r) + if err != nil { + t.Fatal(err) + } + return der +} + +func sha256Request(nonce *big.Int, certReq bool) reqForTest { + sum := sha256.Sum256([]byte("artifact")) + return reqForTest{Version: 1, MessageImprint: reqImprint{oidSHA256NP, sum[:]}, Nonce: nonce, CertReq: certReq} +} + +func parseResponse(t *testing.T, der []byte) respForTest { + t.Helper() + var r respForTest + rest, err := asn1.Unmarshal(der, &r) + if err != nil || len(rest) != 0 { + t.Fatalf("the response is not a TimeStampResp: %v (%d trailing bytes)", err, len(rest)) + } + return r +} + +// verifiedToken checks a granted response end to end: the SignedData +// verifies under the TSA certificate, the content is a TSTInfo, and the +// signing-certificate-v2 attribute names the TSA certificate. It returns the +// parsed TSTInfo and SignedData. +func verifiedToken(t *testing.T, der []byte, tsaCert *x509.Certificate) (tstInfoForTest, *cms.SignedData) { + t.Helper() + r := parseResponse(t, der) + if r.Status.Status != 0 { + t.Fatalf("status = %d (%v), want granted", r.Status.Status, r.Status.StatusString) + } + if r.Status.FailInfo.BitLength != 0 { + t.Fatal("a granted response carries failInfo") + } + sd, err := cms.ParseSignedData(r.Token.FullBytes) + if err != nil { + t.Fatalf("the token is not a SignedData: %v", err) + } + if !sd.ContentType.Equal(cms.OIDTSTInfo) { + t.Fatalf("eContentType = %s, want id-ct-TSTInfo", sd.ContentType) + } + if sd.Version != 3 { + t.Errorf("SignedData version = %d, want 3", sd.Version) + } + signers, err := sd.Verify(cms.VerifyOptions{Certificates: []*x509.Certificate{tsaCert}}) + if err != nil { + t.Fatalf("the token does not verify: %v", err) + } + if len(signers) != 1 || !signers[0].Equal(tsaCert) { + t.Fatal("the token is not signed by the TSA certificate") + } + si := sd.SignerInfos[0] + if si.IssuerAndSerial == nil { + t.Fatal("the signer is not identified by issuer and serial number") + } + + var scv2 signingCertV2ForTest + if err := si.SignedAttribute(oidSigningCertificateV2, &scv2); err != nil { + t.Fatalf("signing-certificate-v2: %v", err) + } + if len(scv2.Certs) != 1 { + t.Fatalf("signing-certificate-v2 names %d certificates, want 1", len(scv2.Certs)) + } + id := scv2.Certs[0] + want := sha256.Sum256(tsaCert.Raw) + if !bytes.Equal(id.CertHash, want[:]) { + t.Error("ESSCertIDv2 certHash is not the SHA-256 of the TSA certificate") + } + var first asn1.RawValue + if _, err := asn1.Unmarshal(id.Raw, &first); err != nil { + t.Fatal(err) + } + if inner, _ := splitForTest(first.Bytes); len(inner) == 0 || inner[0].Tag != asn1.TagOctetString { + t.Error("ESSCertIDv2 spells out its hash algorithm; SHA-256 is the DEFAULT and DER leaves it out") + } + if id.IssuerSerial.Serial.Cmp(tsaCert.SerialNumber) != 0 { + t.Error("ESSCertIDv2 issuerSerial has the wrong serial") + } + if len(id.IssuerSerial.Issuer) != 1 || id.IssuerSerial.Issuer[0].Tag != 4 || !bytes.Equal(id.IssuerSerial.Issuer[0].Bytes, tsaCert.RawIssuer) { + t.Error("ESSCertIDv2 issuerSerial does not name the TSA certificate's issuer as a directoryName") + } + + var info tstInfoForTest + rest, err := asn1.Unmarshal(sd.Content, &info) + if err != nil || len(rest) != 0 { + t.Fatalf("the content is not a TSTInfo: %v", err) + } + return info, sd +} + +func splitForTest(b []byte) ([]asn1.RawValue, error) { + var out []asn1.RawValue + for len(b) > 0 { + var v asn1.RawValue + rest, err := asn1.Unmarshal(b, &v) + if err != nil { + return nil, err + } + out = append(out, v) + b = rest + } + return out, nil +} + +func TestRespondGrantsAVerifiableToken(t *testing.T) { + _, s := newTSA(t) + gen := time.Date(2026, 10, 6, 20, 15, 30, 250_400_000, time.UTC) + r := newTestResponder(t, s, func() time.Time { return gen }) + nonce, _ := new(big.Int).SetString("123456789abcdef0123456789abcdef", 16) + reqDER := encodeRequest(t, sha256Request(nonce, false)) + + resp, out, err := r.Respond(context.Background(), reqDER) + if err != nil || !out.Granted { + t.Fatalf("Respond: granted=%t err=%v reason=%s", out.Granted, err, out.Reason) + } + info, sd := verifiedToken(t, resp, s.cert) + + if info.Version != 1 { + t.Errorf("TSTInfo version = %d", info.Version) + } + if !info.Policy.Equal(testPolicy) { + t.Errorf("policy = %s, want %s", info.Policy, testPolicy) + } + var req reqForTest + if _, err := asn1.Unmarshal(reqDER, &req); err != nil { + t.Fatal(err) + } + wantImprint, _ := asn1.Marshal(req.MessageImprint) + if !bytes.Equal(info.MessageImprint.FullBytes, wantImprint) { + t.Error("the token does not echo the message imprint") + } + if info.Nonce == nil || info.Nonce.Cmp(nonce) != 0 { + t.Errorf("nonce = %v, want %v", info.Nonce, nonce) + } + if info.SerialNumber.Cmp(out.Serial) != 0 || info.SerialNumber.Sign() <= 0 || info.SerialNumber.BitLen() > 160 { + t.Errorf("serial = %v", info.SerialNumber) + } + if info.GenTime.Tag != asn1.TagGeneralizedTime || string(info.GenTime.Bytes) != "20261006201530.25Z" { + t.Errorf("genTime = tag %d %q, want GeneralizedTime 20261006201530.25Z", info.GenTime.Tag, info.GenTime.Bytes) + } + if info.Accuracy.Seconds != 1 || info.Accuracy.Millis != 500 || info.Accuracy.Micros != 0 { + t.Errorf("accuracy = %+v, want 1s 500ms", info.Accuracy) + } + if info.Ordering { + t.Error("ordering is claimed") + } + if len(sd.Certificates) != 0 { + t.Error("the token carries certificates though certReq was false") + } + if got := sd.SignerInfos[0].DigestAlgorithm.Algorithm; !got.Equal(oidSHA384) { + t.Errorf("token digest = %s, want SHA-384 for a P-384 key", got) + } +} + +func TestRespondCarriesTheTSACertificateWhenAsked(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + resp, out, _ := r.Respond(context.Background(), encodeRequest(t, sha256Request(nil, true))) + if !out.Granted { + t.Fatalf("rejected: %s", out.Reason) + } + info, sd := verifiedToken(t, resp, s.cert) + if len(sd.Certificates) != 1 || !bytes.Equal(sd.Certificates[0], s.cert.Raw) { + t.Fatalf("certificates = %d, want exactly the TSA certificate", len(sd.Certificates)) + } + if info.Nonce != nil { + t.Error("a nonce appears though the request sent none") + } +} + +func TestRespondAcceptsSHA384AndSHA512Imprints(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + s384 := sha512.Sum384([]byte("x")) + s512 := sha512.Sum512([]byte("x")) + for name, mi := range map[string]reqImprint{ + "SHA-384": {pkix.AlgorithmIdentifier{Algorithm: oidSHA384}, s384[:]}, + "SHA-512": {pkix.AlgorithmIdentifier{Algorithm: oidSHA512}, s512[:]}, + "SHA-256 with NULL": {pkix.AlgorithmIdentifier{Algorithm: oidSHA256, Parameters: asn1.NullRawValue}, s512[:32]}, + } { + t.Run(name, func(t *testing.T) { + resp, out, _ := r.Respond(context.Background(), encodeRequest(t, reqForTest{Version: 1, MessageImprint: mi})) + if !out.Granted { + t.Fatalf("rejected: %s", out.Reason) + } + verifiedToken(t, resp, s.cert) + }) + } +} + +func assertRejected(t *testing.T, resp []byte, out Outcome, want FailureInfo) { + t.Helper() + if out.Granted { + t.Fatal("granted, want a rejection") + } + if out.Fail != want { + t.Errorf("outcome failInfo = %s, want %s", out.Fail, want) + } + r := parseResponse(t, resp) + if r.Status.Status != 2 { + t.Errorf("status = %d, want rejection (2)", r.Status.Status) + } + if len(r.Token.FullBytes) != 0 { + t.Error("a rejection carries a token") + } + fi := r.Status.FailInfo + if fi.BitLength != int(want)+1 || fi.At(int(want)) != 1 { + t.Errorf("failInfo = %d bits, want bit %d (%s) set and last", fi.BitLength, want, want) + } + for i := 0; i < fi.BitLength; i++ { + if i != int(want) && fi.At(i) != 0 { + t.Errorf("failInfo bit %d is set too", i) + } + } + if len(r.Status.StatusString) != 1 || r.Status.StatusString[0] == "" { + t.Error("a rejection has no statusString") + } +} + +func TestRespondRefusesWeakAndUnknownImprintAlgorithmsWithBadAlg(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + s1 := sha1.Sum([]byte("x")) + for name, mi := range map[string]reqImprint{ + "SHA-1": {pkix.AlgorithmIdentifier{Algorithm: oidSHA1}, s1[:]}, + "MD5": {pkix.AlgorithmIdentifier{Algorithm: oidMD5}, make([]byte, 16)}, + "SHA-224": {pkix.AlgorithmIdentifier{Algorithm: oidSHA224}, make([]byte, 28)}, + "SHA-256 w/ args": {pkix.AlgorithmIdentifier{Algorithm: oidSHA256, Parameters: asn1.RawValue{FullBytes: []byte{0x02, 0x01, 0x01}}}, make([]byte, 32)}, + } { + t.Run(name, func(t *testing.T) { + resp, out, err := r.Respond(context.Background(), encodeRequest(t, reqForTest{Version: 1, MessageImprint: mi})) + if err != nil { + t.Fatal(err) + } + assertRejected(t, resp, out, FailBadAlg) + }) + } +} + +func TestRespondRefusesMalformedRequestsWithBadDataFormat(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + good := encodeRequest(t, sha256Request(nil, false)) + v2 := sha256Request(nil, false) + v2.Version = 2 + short := sha256Request(nil, false) + short.MessageImprint.HashedMessage = short.MessageImprint.HashedMessage[:31] + for name, der := range map[string][]byte{ + "garbage": []byte("not a request"), + "empty": nil, + "trailing bytes": append(append([]byte{}, good...), 0x00), + "version 2": encodeRequest(t, v2), + "short imprint": encodeRequest(t, short), + } { + t.Run(name, func(t *testing.T) { + resp, out, err := r.Respond(context.Background(), der) + if err != nil { + t.Fatal(err) + } + assertRejected(t, resp, out, FailBadDataFormat) + }) + } +} + +func TestRespondHonoursTheRequestedPolicy(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + same := sha256Request(nil, false) + same.ReqPolicy = testPolicy + resp, out, _ := r.Respond(context.Background(), encodeRequest(t, same)) + if !out.Granted { + t.Fatalf("a request for the served policy was rejected: %s", out.Reason) + } + if info, _ := verifiedToken(t, resp, s.cert); !info.Policy.Equal(testPolicy) { + t.Error("wrong policy in the token") + } + + other := sha256Request(nil, false) + other.ReqPolicy = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 32473, 9} + resp, out, _ = r.Respond(context.Background(), encodeRequest(t, other)) + assertRejected(t, resp, out, FailUnacceptedPolicy) +} + +func TestRespondRefusesRequestExtensionsWithUnacceptedExtension(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + base := encodeRequest(t, sha256Request(nil, false)) + var outer asn1.RawValue + if _, err := asn1.Unmarshal(base, &outer); err != nil { + t.Fatal(err) + } + ext, _ := asn1.Marshal(pkix.Extension{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 32473, 2}, Value: []byte{0x05, 0x00}}) + exts := tlv(0xa0, ext) + der := tlv(0x30, outer.Bytes, exts) + resp, out, err := r.Respond(context.Background(), der) + if err != nil { + t.Fatal(err) + } + assertRejected(t, resp, out, FailUnacceptedExtension) +} + +func TestRespondAnswersSystemFailureWhenSigningFails(t *testing.T) { + _, s := newTSA(t) + s.err = errors.New("TPM unavailable") + r := newTestResponder(t, s, nil) + resp, out, err := r.Respond(context.Background(), encodeRequest(t, sha256Request(nil, false))) + if err != nil { + t.Fatal(err) + } + assertRejected(t, resp, out, FailSystemFailure) +} + +func TestRespondGivesEveryTokenItsOwnSerial(t *testing.T) { + _, s := newTSA(t) + r := newTestResponder(t, s, nil) + seen := map[string]bool{} + for range 64 { + _, out, _ := r.Respond(context.Background(), encodeRequest(t, sha256Request(nil, false))) + if !out.Granted { + t.Fatalf("rejected: %s", out.Reason) + } + k := out.Serial.Text(16) + if seen[k] { + t.Fatalf("serial %s issued twice", k) + } + seen[k] = true + } +} + +func TestRespondSignsWithAnRSATSAKey(t *testing.T) { + authority := newTestCA(t, "Example Issuing CA G1") + key, err := rsa.GenerateKey(rand.Reader, 3072) + if err != nil { + t.Fatal(err) + } + s := &staticSigner{cert: issueTSACert(t, authority, key), key: key} + r := newTestResponder(t, s, nil) + resp, out, _ := r.Respond(context.Background(), encodeRequest(t, sha256Request(big.NewInt(7), true))) + if !out.Granted { + t.Fatalf("rejected: %s", out.Reason) + } + _, sd := verifiedToken(t, resp, s.cert) + if got := sd.SignerInfos[0].DigestAlgorithm.Algorithm; !got.Equal(oidSHA384) { + t.Errorf("token digest = %s, want SHA-384 for RSA 3072", got) + } +} + +func TestGeneralizedTimeDropsTrailingZeros(t *testing.T) { + re := regexp.MustCompile(`^\d{14}(\.\d*[1-9])?Z$`) + for in, want := range map[time.Time]string{ + time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC): "20260102030405Z", + time.Date(2026, 1, 2, 3, 4, 5, 100_000_000, time.UTC): "20260102030405.1Z", + time.Date(2026, 1, 2, 3, 4, 5, 123_999_999, time.UTC): "20260102030405.123Z", + time.Date(2026, 1, 2, 3, 4, 5, 999_000, time.UTC): "20260102030405Z", + } { + enc := generalizedTime(in) + var v asn1.RawValue + if _, err := asn1.Unmarshal(enc, &v); err != nil { + t.Fatal(err) + } + if got := string(v.Bytes); got != want || !re.MatchString(got) { + t.Errorf("generalizedTime(%s) = %q, want %q", in, got, want) + } + } +} + +func TestNewResponderRequiresAPolicyAndAnAccuracy(t *testing.T) { + _, s := newTSA(t) + if _, err := NewResponder(s, ResponderOptions{Accuracy: time.Second}); err == nil { + t.Error("NewResponder accepted no policy") + } + if _, err := NewResponder(s, ResponderOptions{Policy: testPolicy}); err == nil { + t.Error("NewResponder accepted no accuracy") + } +} diff --git a/internal/tsa/token.go b/internal/tsa/token.go new file mode 100644 index 0000000..61cef4f --- /dev/null +++ b/internal/tsa/token.go @@ -0,0 +1,277 @@ +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 ( + "crypto" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/CryptOS-PKI/cryptos-node/internal/ca" + "github.com/CryptOS-PKI/cryptos-node/internal/cms" +) + +// oidSigningCertificateV2 is id-aa-signingCertificateV2 (RFC 5035 section +// 3), the attribute RFC 5816 lets a token identify its signer with by a +// SHA-2 hash. +var oidSigningCertificateV2 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 16, 2, 47} + +// PKIStatus values (RFC 3161 section 2.4.2). +const ( + statusGranted = 0 + statusRejection = 2 +) + +// tokenParams are the per-token values the responder decides. +type tokenParams struct { + policy asn1.ObjectIdentifier + accuracy time.Duration + serial *big.Int + genTime time.Time +} + +// newSerial returns a token serial number: 159 random bits, positive and +// non-zero, within the 160 bits RFC 3161 section 2.4.2 says a requester must +// be able to handle. Two tokens sharing one is as likely as a collision of +// random 159-bit values. +func newSerial() (*big.Int, error) { + b := make([]byte, 20) + for { + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("tsa: generate a serial number: %w", err) + } + b[0] &= 0x7f + if s := new(big.Int).SetBytes(b); s.Sign() > 0 { + return s, nil + } + } +} + +// tlv encodes one DER element with a short- or long-form length. +func tlv(tag byte, parts ...[]byte) []byte { + n := 0 + for _, p := range parts { + n += len(p) + } + out := []byte{tag} + switch { + case n < 0x80: + out = append(out, byte(n)) + case n < 0x100: + out = append(out, 0x81, byte(n)) + case n < 0x10000: + out = append(out, 0x82, byte(n>>8), byte(n)) + default: + out = append(out, 0x83, byte(n>>16), byte(n>>8), byte(n)) + } + for _, p := range parts { + out = append(out, p...) + } + return out +} + +// generalizedTime encodes t in UTC as a DER GeneralizedTime with the +// millisecond fraction RFC 3161 section 2.4.2 allows: trailing zeros +// dropped, and no decimal point for a whole second. encoding/asn1 writes +// whole seconds only. +func generalizedTime(t time.Time) []byte { + t = t.UTC().Truncate(time.Millisecond) + s := t.Format("20060102150405") + if ms := t.Nanosecond() / int(time.Millisecond); ms != 0 { + s += "." + strings.TrimRight(fmt.Sprintf("%03d", ms), "0") + } + return tlv(0x18, []byte(s+"Z")) +} + +// accuracy encodes d as an Accuracy (RFC 3161 section 2.4.2): seconds, and +// millis in 1..999 when there is a remainder. A field that would be zero is +// left out. +func accuracy(d time.Duration) ([]byte, error) { + ms := d.Milliseconds() + if ms <= 0 { + return nil, errors.New("tsa: the accuracy must be at least one millisecond") + } + var parts [][]byte + if s := ms / 1000; s > 0 { + enc, err := asn1.Marshal(s) + if err != nil { + return nil, err + } + parts = append(parts, enc) + } + if rem := ms % 1000; rem > 0 { + enc, err := asn1.Marshal(rem) + if err != nil { + return nil, err + } + // millis [0] IMPLICIT INTEGER: the INTEGER's contents under tag + // [0]. + enc[0] = 0x80 + parts = append(parts, enc) + } + return tlv(0x30, parts...), nil +} + +// tstInfo encodes the TSTInfo (RFC 3161 section 2.4.2) for req. ordering is +// left at its default, FALSE: the TSA claims no ordering between tokens +// beyond what genTime and the accuracy say. The tsa name and extensions are +// left out. +func tstInfo(req *Request, p tokenParams) ([]byte, error) { + version, err := asn1.Marshal(1) + if err != nil { + return nil, err + } + policy, err := asn1.Marshal(p.policy) + if err != nil { + return nil, fmt.Errorf("tsa: encode the policy: %w", err) + } + serial, err := asn1.Marshal(p.serial) + if err != nil { + return nil, err + } + acc, err := accuracy(p.accuracy) + if err != nil { + return nil, err + } + parts := [][]byte{version, policy, req.rawImprint, serial, generalizedTime(p.genTime), acc} + if req.Nonce != nil { + nonce, err := asn1.Marshal(req.Nonce) + if err != nil { + return nil, err + } + parts = append(parts, nonce) + } + return tlv(0x30, parts...), nil +} + +// signingCertificateV2 is the signing-certificate-v2 attribute naming cert +// (RFC 5035 section 5.4.1.1, RFC 5816 section 2.2.1): one ESSCertIDv2 with +// the certificate's SHA-256 hash, the default algorithm and so left out, and +// its issuer and serial number. +func signingCertificateV2(cert *x509.Certificate) (cms.Attribute, error) { + hash := sha256.Sum256(cert.Raw) + certHash, err := asn1.Marshal(hash[:]) + if err != nil { + return cms.Attribute{}, err + } + serial, err := asn1.Marshal(cert.SerialNumber) + if err != nil { + return cms.Attribute{}, err + } + // GeneralNames holding one directoryName [4]; Name is a CHOICE, so the + // tag is explicit. + issuerSerial := tlv(0x30, tlv(0x30, tlv(0xa4, cert.RawIssuer)), serial) + essCertID := tlv(0x30, certHash, issuerSerial) + value := tlv(0x30, tlv(0x30, essCertID)) + return cms.Attribute{Type: oidSigningCertificateV2, Values: []asn1.RawValue{{FullBytes: value}}}, nil +} + +// signatureHash is the digest a TSA key signs with: the one the node pairs +// with that key when it signs certificates (SHA-384 for P-384 and RSA 3072 or +// larger). +func signatureHash(pub crypto.PublicKey) (crypto.Hash, error) { + alg, err := ca.SignatureAlgorithmFor(pub) + if err != nil { + return 0, fmt.Errorf("tsa: TSA key: %w", err) + } + switch alg { + case x509.ECDSAWithSHA384, x509.SHA384WithRSA: + return crypto.SHA384, nil + case x509.SHA256WithRSA: + return crypto.SHA256, nil + default: + return 0, fmt.Errorf("tsa: TSA key: no digest for signature algorithm %s", alg) + } +} + +// signToken wraps an encoded TSTInfo in a SignedData signed by key under +// cert: the TimeStampToken (RFC 3161 section 2.4.2). The TSA certificate is +// carried when the requester asked for it. +func signToken(info []byte, cert *x509.Certificate, key crypto.Signer, includeCert bool) ([]byte, error) { + h, err := signatureHash(key.Public()) + if err != nil { + return nil, err + } + attr, err := signingCertificateV2(cert) + if err != nil { + return nil, err + } + var opts cms.SignOptions + if includeCert { + opts.Certificates = [][]byte{cert.Raw} + } + return cms.Sign(cms.OIDTSTInfo, info, []cms.Signer{{ + Certificate: cert, + Key: key, + Hash: h, + Attributes: []cms.Attribute{attr}, + }}, opts) +} + +// statusInfo encodes a PKIStatusInfo. failInfo is ignored for granted. +func statusInfo(status int, fail FailureInfo, text string) ([]byte, error) { + st, err := asn1.Marshal(status) + if err != nil { + return nil, err + } + parts := [][]byte{st} + if text != "" { + s, err := asn1.MarshalWithParams(text, "utf8") + if err != nil { + return nil, err + } + parts = append(parts, tlv(0x30, s)) + } + if status != statusGranted { + // A named BIT STRING in DER ends at its last set bit. + bits := int(fail) + 1 + buf := make([]byte, (bits+7)/8) + buf[int(fail)/8] = 0x80 >> (uint(fail) % 8) + fi, err := asn1.Marshal(asn1.BitString{Bytes: buf, BitLength: bits}) + if err != nil { + return nil, err + } + parts = append(parts, fi) + } + return tlv(0x30, parts...), nil +} + +// grantedResponse is a TimeStampResp carrying token. +func grantedResponse(token []byte) ([]byte, error) { + st, err := statusInfo(statusGranted, 0, "") + if err != nil { + return nil, err + } + return tlv(0x30, st, token), nil +} + +// rejectionResponse is a TimeStampResp refusing the request with fail and +// text, and no token. +func rejectionResponse(fail FailureInfo, text string) ([]byte, error) { + st, err := statusInfo(statusRejection, fail, text) + if err != nil { + return nil, err + } + return tlv(0x30, st), nil +}