588 lines
18 KiB
Go
588 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/asn1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"math/big"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/pkcs11"
|
|
|
|
"github.com/letsencrypt/boulder/pkcs11helpers"
|
|
"github.com/letsencrypt/boulder/test"
|
|
)
|
|
|
|
// samplePubkey returns a slice of bytes containing an encoded
|
|
// SubjectPublicKeyInfo for an example public key.
|
|
func samplePubkey() []byte {
|
|
pubKey, err := hex.DecodeString("3059301306072a8648ce3d020106082a8648ce3d03010703420004b06745ef0375c9c54057098f077964e18d3bed0aacd54545b16eab8c539b5768cc1cea93ba56af1e22a7a01c33048c8885ed17c9c55ede70649b707072689f5e")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return pubKey
|
|
}
|
|
|
|
func realRand(_ pkcs11.SessionHandle, length int) ([]byte, error) {
|
|
r := make([]byte, length)
|
|
_, err := rand.Read(r)
|
|
return r, err
|
|
}
|
|
|
|
func TestParseOID(t *testing.T) {
|
|
_, err := parseOID("")
|
|
test.AssertError(t, err, "parseOID accepted an empty OID")
|
|
_, err = parseOID("a.b.c")
|
|
test.AssertError(t, err, "parseOID accepted an OID containing non-ints")
|
|
_, err = parseOID("1.0.2")
|
|
test.AssertError(t, err, "parseOID accepted an OID containing zero")
|
|
oid, err := parseOID("1.2.3")
|
|
test.AssertNotError(t, err, "parseOID failed with a valid OID")
|
|
test.Assert(t, oid.Equal(asn1.ObjectIdentifier{1, 2, 3}), "parseOID returned incorrect OID")
|
|
}
|
|
|
|
func TestMakeSubject(t *testing.T) {
|
|
profile := &certProfile{
|
|
CommonName: "common name",
|
|
Organization: "organization",
|
|
Country: "country",
|
|
}
|
|
expectedSubject := pkix.Name{
|
|
CommonName: "common name",
|
|
Organization: []string{"organization"},
|
|
Country: []string{"country"},
|
|
}
|
|
test.AssertDeepEquals(t, profile.Subject(), expectedSubject)
|
|
}
|
|
|
|
func TestMakeTemplateRoot(t *testing.T) {
|
|
s, ctx := pkcs11helpers.NewSessionWithMock()
|
|
profile := &certProfile{}
|
|
randReader := newRandReader(s)
|
|
pubKey := samplePubkey()
|
|
ctx.GenerateRandomFunc = realRand
|
|
|
|
profile.NotBefore = "1234"
|
|
_, err := makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with invalid not before")
|
|
|
|
profile.NotBefore = "2018-05-18 11:31:00"
|
|
profile.NotAfter = "1234"
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with invalid not after")
|
|
|
|
profile.NotAfter = "2018-05-18 11:31:00"
|
|
profile.SignatureAlgorithm = "nope"
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with invalid signature algorithm")
|
|
|
|
profile.SignatureAlgorithm = "SHA256WithRSA"
|
|
ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) {
|
|
return nil, errors.New("bad")
|
|
}
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail when GenerateRandom failed")
|
|
|
|
ctx.GenerateRandomFunc = realRand
|
|
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with empty key usages")
|
|
|
|
profile.KeyUsages = []string{"asd"}
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with invalid key usages")
|
|
|
|
profile.KeyUsages = []string{"Digital Signature", "CRL Sign"}
|
|
profile.Policies = []policyInfoConfig{{}}
|
|
_, err = makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertError(t, err, "makeTemplate didn't fail with invalid (empty) policy OID")
|
|
|
|
profile.Policies = []policyInfoConfig{{OID: "1.2.3"}, {OID: "1.2.3.4"}}
|
|
profile.CommonName = "common name"
|
|
profile.Organization = "organization"
|
|
profile.Country = "country"
|
|
profile.OCSPURL = "ocsp"
|
|
profile.CRLURL = "crl"
|
|
profile.IssuerURL = "issuer"
|
|
cert, err := makeTemplate(randReader, profile, pubKey, nil, rootCert)
|
|
test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected")
|
|
test.AssertEquals(t, cert.Subject.CommonName, profile.CommonName)
|
|
test.AssertEquals(t, len(cert.Subject.Organization), 1)
|
|
test.AssertEquals(t, cert.Subject.Organization[0], profile.Organization)
|
|
test.AssertEquals(t, len(cert.Subject.Country), 1)
|
|
test.AssertEquals(t, cert.Subject.Country[0], profile.Country)
|
|
test.AssertEquals(t, len(cert.OCSPServer), 1)
|
|
test.AssertEquals(t, cert.OCSPServer[0], profile.OCSPURL)
|
|
test.AssertEquals(t, len(cert.CRLDistributionPoints), 1)
|
|
test.AssertEquals(t, cert.CRLDistributionPoints[0], profile.CRLURL)
|
|
test.AssertEquals(t, len(cert.IssuingCertificateURL), 1)
|
|
test.AssertEquals(t, cert.IssuingCertificateURL[0], profile.IssuerURL)
|
|
test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageDigitalSignature|x509.KeyUsageCRLSign)
|
|
test.AssertEquals(t, len(cert.Policies), 2)
|
|
test.AssertEquals(t, len(cert.ExtKeyUsage), 0)
|
|
|
|
cert, err = makeTemplate(randReader, profile, pubKey, nil, intermediateCert)
|
|
test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected")
|
|
test.Assert(t, cert.MaxPathLenZero, "MaxPathLenZero not set in intermediate template")
|
|
test.AssertEquals(t, len(cert.ExtKeyUsage), 2)
|
|
test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageClientAuth)
|
|
test.AssertEquals(t, cert.ExtKeyUsage[1], x509.ExtKeyUsageServerAuth)
|
|
}
|
|
|
|
func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) {
|
|
s, ctx := pkcs11helpers.NewSessionWithMock()
|
|
ctx.GenerateRandomFunc = realRand
|
|
randReader := newRandReader(s)
|
|
pubKey := samplePubkey()
|
|
profile := &certProfile{
|
|
SignatureAlgorithm: "SHA256WithRSA",
|
|
CommonName: "common name",
|
|
Organization: "organization",
|
|
Country: "country",
|
|
KeyUsages: []string{"Digital Signature", "CRL Sign"},
|
|
OCSPURL: "ocsp",
|
|
CRLURL: "crl",
|
|
IssuerURL: "issuer",
|
|
NotAfter: "2020-10-10 11:31:00",
|
|
NotBefore: "2020-10-10 11:31:00",
|
|
}
|
|
|
|
tbcsCert := x509.Certificate{
|
|
SerialNumber: big.NewInt(666),
|
|
Subject: pkix.Name{
|
|
Organization: []string{"While Eek Ayote"},
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
cert, err := makeTemplate(randReader, profile, pubKey, &tbcsCert, crossCert)
|
|
test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected")
|
|
test.Assert(t, !cert.MaxPathLenZero, "MaxPathLenZero was set in cross-sign")
|
|
test.AssertEquals(t, len(cert.ExtKeyUsage), 1)
|
|
test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageServerAuth)
|
|
}
|
|
|
|
func TestMakeTemplateOCSP(t *testing.T) {
|
|
s, ctx := pkcs11helpers.NewSessionWithMock()
|
|
ctx.GenerateRandomFunc = realRand
|
|
randReader := newRandReader(s)
|
|
profile := &certProfile{
|
|
SignatureAlgorithm: "SHA256WithRSA",
|
|
CommonName: "common name",
|
|
Organization: "organization",
|
|
Country: "country",
|
|
OCSPURL: "ocsp",
|
|
CRLURL: "crl",
|
|
IssuerURL: "issuer",
|
|
NotAfter: "2018-05-18 11:31:00",
|
|
NotBefore: "2018-05-18 11:31:00",
|
|
}
|
|
pubKey := samplePubkey()
|
|
|
|
cert, err := makeTemplate(randReader, profile, pubKey, nil, ocspCert)
|
|
test.AssertNotError(t, err, "makeTemplate failed")
|
|
|
|
test.Assert(t, !cert.IsCA, "IsCA is set")
|
|
// Check KU is only KeyUsageDigitalSignature
|
|
test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageDigitalSignature)
|
|
// Check there is a single EKU with id-kp-OCSPSigning
|
|
test.AssertEquals(t, len(cert.ExtKeyUsage), 1)
|
|
test.AssertEquals(t, cert.ExtKeyUsage[0], x509.ExtKeyUsageOCSPSigning)
|
|
// Check ExtraExtensions contains a single id-pkix-ocsp-nocheck
|
|
hasExt := false
|
|
asnNULL := []byte{5, 0}
|
|
for _, ext := range cert.ExtraExtensions {
|
|
if ext.Id.Equal(oidOCSPNoCheck) {
|
|
if hasExt {
|
|
t.Error("template contains multiple id-pkix-ocsp-nocheck extensions")
|
|
}
|
|
hasExt = true
|
|
if !bytes.Equal(ext.Value, asnNULL) {
|
|
t.Errorf("id-pkix-ocsp-nocheck has unexpected content: want %x, got %x", asnNULL, ext.Value)
|
|
}
|
|
}
|
|
}
|
|
test.Assert(t, hasExt, "template doesn't contain id-pkix-ocsp-nocheck extensions")
|
|
}
|
|
|
|
func TestMakeTemplateCRL(t *testing.T) {
|
|
s, ctx := pkcs11helpers.NewSessionWithMock()
|
|
ctx.GenerateRandomFunc = realRand
|
|
randReader := newRandReader(s)
|
|
profile := &certProfile{
|
|
SignatureAlgorithm: "SHA256WithRSA",
|
|
CommonName: "common name",
|
|
Organization: "organization",
|
|
Country: "country",
|
|
OCSPURL: "ocsp",
|
|
CRLURL: "crl",
|
|
IssuerURL: "issuer",
|
|
NotAfter: "2018-05-18 11:31:00",
|
|
NotBefore: "2018-05-18 11:31:00",
|
|
}
|
|
pubKey := samplePubkey()
|
|
|
|
cert, err := makeTemplate(randReader, profile, pubKey, nil, crlCert)
|
|
test.AssertNotError(t, err, "makeTemplate failed")
|
|
|
|
test.Assert(t, !cert.IsCA, "IsCA is set")
|
|
test.AssertEquals(t, cert.KeyUsage, x509.KeyUsageCRLSign)
|
|
}
|
|
|
|
func TestVerifyProfile(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
profile certProfile
|
|
certType []certType
|
|
expectedErr string
|
|
}{
|
|
{
|
|
profile: certProfile{},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "not-before is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "not-after is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "signature-algorithm is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "common-name is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "organization is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "country is required",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
OCSPURL: "g",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "crl-url is required for subordinate CAs",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
OCSPURL: "g",
|
|
CRLURL: "h",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "issuer-url is required for subordinate CAs",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
OCSPURL: "g",
|
|
CRLURL: "h",
|
|
IssuerURL: "i",
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "policy should be exactly BRs domain-validated for subordinate CAs",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
OCSPURL: "g",
|
|
CRLURL: "h",
|
|
IssuerURL: "i",
|
|
Policies: []policyInfoConfig{{OID: "1.2.3"}, {OID: "4.5.6"}},
|
|
},
|
|
certType: []certType{intermediateCert, crossCert},
|
|
expectedErr: "policy should be exactly BRs domain-validated for subordinate CAs",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
},
|
|
certType: []certType{rootCert},
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
KeyUsages: []string{"j"},
|
|
},
|
|
certType: []certType{ocspCert},
|
|
expectedErr: "key-usages cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
CRLURL: "i",
|
|
},
|
|
certType: []certType{ocspCert},
|
|
expectedErr: "crl-url cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
OCSPURL: "h",
|
|
},
|
|
certType: []certType{ocspCert},
|
|
expectedErr: "ocsp-url cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
},
|
|
certType: []certType{ocspCert},
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
KeyUsages: []string{"j"},
|
|
},
|
|
certType: []certType{crlCert},
|
|
expectedErr: "key-usages cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
CRLURL: "i",
|
|
},
|
|
certType: []certType{crlCert},
|
|
expectedErr: "crl-url cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
OCSPURL: "h",
|
|
},
|
|
certType: []certType{crlCert},
|
|
expectedErr: "ocsp-url cannot be set for a delegated signer",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
NotAfter: "b",
|
|
SignatureAlgorithm: "c",
|
|
CommonName: "d",
|
|
Organization: "e",
|
|
Country: "f",
|
|
IssuerURL: "g",
|
|
},
|
|
certType: []certType{crlCert},
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotBefore: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "not-before cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
NotAfter: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "not-after cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
SignatureAlgorithm: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "signature-algorithm cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
OCSPURL: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "ocsp-url cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
CRLURL: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "crl-url cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
IssuerURL: "a",
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "issuer-url cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
Policies: []policyInfoConfig{{OID: "1.2.3"}},
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "policies cannot be set for a CSR",
|
|
},
|
|
{
|
|
profile: certProfile{
|
|
KeyUsages: []string{"a"},
|
|
},
|
|
certType: []certType{requestCert},
|
|
expectedErr: "key-usages cannot be set for a CSR",
|
|
},
|
|
} {
|
|
for _, ct := range tc.certType {
|
|
err := tc.profile.verifyProfile(ct)
|
|
if err != nil {
|
|
if tc.expectedErr != err.Error() {
|
|
t.Fatalf("Expected %q, got %q", tc.expectedErr, err.Error())
|
|
}
|
|
} else if tc.expectedErr != "" {
|
|
t.Fatalf("verifyProfile didn't fail, expected %q", tc.expectedErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateCSR(t *testing.T) {
|
|
profile := &certProfile{
|
|
CommonName: "common name",
|
|
Organization: "organization",
|
|
Country: "country",
|
|
}
|
|
|
|
signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
test.AssertNotError(t, err, "failed to generate test key")
|
|
|
|
csrBytes, err := generateCSR(profile, &wrappedSigner{signer})
|
|
test.AssertNotError(t, err, "failed to generate CSR")
|
|
|
|
csr, err := x509.ParseCertificateRequest(csrBytes)
|
|
test.AssertNotError(t, err, "failed to parse CSR")
|
|
test.AssertNotError(t, csr.CheckSignature(), "CSR signature check failed")
|
|
test.AssertEquals(t, len(csr.Extensions), 0)
|
|
|
|
test.AssertEquals(t, csr.Subject.String(), fmt.Sprintf("CN=%s,O=%s,C=%s",
|
|
profile.CommonName, profile.Organization, profile.Country))
|
|
}
|
|
|
|
func TestLoadCert(t *testing.T) {
|
|
_, err := loadCert("../../test/hierarchy/int-e1.cert.pem")
|
|
test.AssertNotError(t, err, "should not have errored")
|
|
|
|
_, err = loadCert("/path/that/will/not/ever/exist/ever")
|
|
test.AssertError(t, err, "should have failed opening certificate at non-existent path")
|
|
test.AssertErrorIs(t, err, fs.ErrNotExist)
|
|
|
|
_, err = loadCert("../../test/hierarchy/int-e1.key.pem")
|
|
test.AssertError(t, err, "should have failed when trying to parse a private key")
|
|
}
|
|
|
|
func TestGenerateSKID(t *testing.T) {
|
|
sha256skid, err := generateSKID(samplePubkey())
|
|
test.AssertNotError(t, err, "Error generating SKID")
|
|
test.AssertEquals(t, len(sha256skid), 20)
|
|
test.AssertEquals(t, cap(sha256skid), 20)
|
|
}
|