diff --git a/go.mod b/go.mod index b19eb91..2163def 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/cloudflare/cfssl v1.6.0 github.com/go-phorce/cov-report v1.1.1-0.20200622030546-3fb510c4b1ba github.com/go-sql-driver/mysql v1.5.0 // indirect + github.com/google/go-cmp v0.5.6 // indirect github.com/hashicorp/go-immutable-radix v1.0.0 + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/jinzhu/copier v0.3.2 github.com/jteeuwen/go-bindata v3.0.7+incompatible github.com/juju/errors v0.0.0-20200330140219-3fe23663418f @@ -18,6 +20,7 @@ require ( github.com/lib/pq v1.7.0 // indirect github.com/mattn/go-sqlite3 v1.14.0 // indirect github.com/mattn/goveralls v0.0.9 + github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/miekg/pkcs11 v1.0.3 github.com/prometheus/client_golang v1.11.0 github.com/rs/cors v1.7.0 @@ -26,12 +29,14 @@ require ( github.com/ugorji/go/codec v1.2.6 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 - golang.org/x/text v0.3.6 // indirect + golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 // indirect golang.org/x/tools v0.1.4 + google.golang.org/genproto v0.0.0-20210426193834-eac7f76ac494 // indirect google.golang.org/grpc v1.38.0 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/natefinch/lumberjack.v2 v2.0.0 - gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 // indirect + gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) replace golang.org/x/text => golang.org/x/text v0.3.6 diff --git a/go.sum b/go.sum index 6470eb3..e700156 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,7 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -115,8 +116,10 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -128,8 +131,9 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -159,8 +163,9 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= @@ -263,8 +268,9 @@ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71 github.com/mattn/goveralls v0.0.6/go.mod h1:h8b4ow6FxSPMQHF6o2ve3qsclnffZjYTNEKmLesRwqw= github.com/mattn/goveralls v0.0.9 h1:XmIwwrO9a9pqSW6IpI89BSCShzQxx0j/oKnnvELQNME= github.com/mattn/goveralls v0.0.9/go.mod h1:FRbM1PS8oVsOe9JtdzAAXM+DsvDMMHcM1C7drGJD8HY= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/pkcs11 v1.0.3 h1:iMwmD7I5225wv84WxIG/bmxz9AXjWvTWIbM/TYHvWtw= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= @@ -396,6 +402,7 @@ github.com/weppos/publicsuffix-go v0.13.0 h1:0Tu1uzLBd1jPn4k6OnMmOPZH/l/9bj9kUOM github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= @@ -436,11 +443,13 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -466,10 +475,13 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -478,6 +490,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -504,8 +517,12 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= @@ -532,6 +549,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4 h1:cVngSRcfgyZCzys3KYOpCFa+4dqX/Oub9tAq00ttGVs= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= @@ -550,8 +568,9 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210426193834-eac7f76ac494 h1:KMgpo2lWy1vfrYjtxPAzR0aNWeAR1UdQykt6sj/hpBY= +google.golang.org/genproto v0.0.0-20210426193834-eac7f76ac494/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -563,6 +582,7 @@ google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -574,8 +594,9 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -609,8 +630,8 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/xpki/authority/authority.go b/xpki/authority/authority.go new file mode 100644 index 0000000..a278323 --- /dev/null +++ b/xpki/authority/authority.go @@ -0,0 +1,110 @@ +package authority + +import ( + "github.com/go-phorce/dolly/xlog" + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/juju/errors" +) + +var logger = xlog.NewPackageLogger("github.com/go-phorce/dolly/xpki", "authority") + +// Authority defines the CA +type Authority struct { + issuers map[string]*Issuer // label => Issuer + issuersByProfile map[string]*Issuer // cert profile => Issuer + + // Crypto holds providers for HSM, SoftHSM, KMS, etc. + crypto *cryptoprov.Crypto +} + +// NewAuthority returns new instance of Authority +func NewAuthority(cfg *Config, crypto *cryptoprov.Crypto) (*Authority, error) { + if cfg.Authority == nil { + return nil, errors.New("missing Authority configuration") + } + + ca := &Authority{ + crypto: crypto, + issuers: make(map[string]*Issuer), + issuersByProfile: make(map[string]*Issuer), + } + + ocspNextUpdate := cfg.Authority.DefaultAIA.GetOCSPExpiry() + crlNextUpdate := cfg.Authority.DefaultAIA.GetCRLExpiry() + crlRenewal := cfg.Authority.DefaultAIA.GetCRLRenewal() + + for _, isscfg := range cfg.Authority.Issuers { + if isscfg.GetDisabled() { + logger.Infof("reason=disabled, issuer=%s", isscfg.Label) + continue + } + + ccfg := isscfg.Copy() + if ccfg.AIA == nil { + ccfg.AIA = cfg.Authority.DefaultAIA.Copy() + } + if ccfg.AIA.CRLRenewal == 0 { + ccfg.AIA.CRLRenewal = crlRenewal + } + if ccfg.AIA.CRLExpiry == 0 { + ccfg.AIA.CRLExpiry = crlNextUpdate + } + if ccfg.AIA.OCSPExpiry == 0 { + ccfg.AIA.OCSPExpiry = ocspNextUpdate + } + if ccfg.AIA.CrlURL == "" { + ccfg.AIA.CrlURL = cfg.Authority.DefaultAIA.CrlURL + } + if ccfg.AIA.OcspURL == "" { + ccfg.AIA.OcspURL = cfg.Authority.DefaultAIA.OcspURL + } + if ccfg.AIA.AiaURL == "" { + ccfg.AIA.AiaURL = cfg.Authority.DefaultAIA.AiaURL + } + issuer, err := NewIssuer(ccfg, crypto) + if err != nil { + return nil, errors.Annotatef(err, "unable to create issuer: %q", isscfg.Label) + } + + ca.issuers[isscfg.Label] = issuer + + for profileName := range isscfg.Profiles { + /* + if is := ca.issuersByProfile[profileName]; is != nil { + return nil, errors.Errorf("profile %q is already registered by %q issuer", profileName, is.Label()) + } + */ + ca.issuersByProfile[profileName] = issuer + } + } + + return ca, nil +} + +// GetIssuerByLabel by label +func (s *Authority) GetIssuerByLabel(label string) (*Issuer, error) { + issuer, ok := s.issuers[label] + if ok { + return issuer, nil + } + return nil, errors.Errorf("issuer not found: %s", label) +} + +// GetIssuerByProfile by profile +func (s *Authority) GetIssuerByProfile(profile string) (*Issuer, error) { + issuer, ok := s.issuersByProfile[profile] + if ok { + return issuer, nil + } + return nil, errors.Errorf("issuer not found for profile: %s", profile) +} + +// Issuers returns a list of issuers +func (s *Authority) Issuers() []*Issuer { + list := make([]*Issuer, 0, len(s.issuers)) + for _, ca := range s.issuers { + list = append(list, ca) + } + + return list +} diff --git a/xpki/authority/authority_test.go b/xpki/authority/authority_test.go new file mode 100644 index 0000000..dbced4c --- /dev/null +++ b/xpki/authority/authority_test.go @@ -0,0 +1,476 @@ +package authority_test + +import ( + "testing" + + "github.com/go-phorce/dolly/algorithms/guid" + "github.com/go-phorce/dolly/xpki/authority" + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/suite" +) + +const ( + ca1CertFile = "/tmp/dolly/certs/test_dolly_issuer2_CA.pem" + ca1KeyFile = "/tmp/dolly/certs/test_dolly_issuer2_CA-key.pem" + ca2CertFile = "/tmp/dolly/certs/test_dolly_issuer2_CA.pem" + ca2KeyFile = "/tmp/dolly/certs/test_dolly_issuer2_CA-key.pem" + caBundleFile = "/tmp/dolly/certs/test_dolly_cabundle.pem" + rootBundleFile = "/tmp/dolly/certs/test_dolly_root_CA.pem" +) + +var ( + falseVal = false + trueVal = true +) + +type testSuite struct { + suite.Suite + + crypto *cryptoprov.Crypto +} + +func (s *testSuite) SetupSuite() { + var err error + + s.Require().NoError(err) + + cryptoprov.Register("SoftHSM", cryptoprov.Crypto11Loader) + s.crypto, err = cryptoprov.Load("/tmp/dolly/softhsm_unittest.json", nil) + s.Require().NoError(err) +} + +func (s *testSuite) TearDownSuite() { +} + +func TestAuthority(t *testing.T) { + suite.Run(t, new(testSuite)) +} + +func (s *testSuite) TestNewAuthority() { + // + // Test empty config + // + cfg := &authority.Config{} + _, err := authority.NewAuthority(cfg, s.crypto) + s.Require().Error(err) + s.Equal("missing Authority configuration", err.Error()) + + cfg, err = authority.LoadConfig("./testdata/ca-config.dev.yaml") + s.Require().NoError(err) + + // + // Test 0 default durations + // + cfg2 := cfg.Copy() + s.Require().Equal(*cfg, *cfg2) + + cfg2.Authority.DefaultAIA = &authority.AIAConfig{ + CRLExpiry: 0, + OCSPExpiry: 0, + CRLRenewal: 0, + } + + _, err = authority.NewAuthority(cfg2, s.crypto) + s.Require().NoError(err) + + // + // Test invalid Issuer files + // + cfg3 := cfg.Copy() + cfg3.Authority.Issuers = []authority.IssuerConfig{ + { + Label: "disabled", + Disabled: &trueVal, + }, + { + Label: "badkey", + KeyFile: "not_found", + }, + } + + _, err = authority.NewAuthority(cfg3, s.crypto) + s.Require().Error(err) + s.Equal("unable to create issuer: \"badkey\": unable to create signer: load key file: open not_found: no such file or directory", err.Error()) + + // + // test default Expiry and Renewal from Authority config + // + cfg4 := cfg.Copy() + for i := range cfg4.Authority.Issuers { + cfg4.Authority.Issuers[i].AIA = &authority.AIAConfig{} + } + + a, err := authority.NewAuthority(cfg4, s.crypto) + s.Require().NoError(err) + issuers := a.Issuers() + s.Equal(len(cfg4.Authority.Issuers), len(issuers)) + + for _, issuer := range issuers { + s.Equal(cfg4.Authority.DefaultAIA.GetCRLRenewal(), issuer.CrlRenewal()) + s.Equal(cfg4.Authority.DefaultAIA.GetCRLExpiry(), issuer.CrlExpiry()) + s.Equal(cfg4.Authority.DefaultAIA.GetOCSPExpiry(), issuer.OcspExpiry()) + s.NotContains(issuer.AiaURL(), "${ISSUER_ID}") + s.NotContains(issuer.CrlURL(), "${ISSUER_ID}") + s.NotContains(issuer.OcspURL(), "${ISSUER_ID}") + + i, err := a.GetIssuerByLabel(issuer.Label()) + s.NoError(err) + s.NotNil(i) + + for name := range cfg.Profiles { + _, err = a.GetIssuerByProfile(name) + s.NoError(err) + } + } + _, err = a.GetIssuerByLabel("wrong") + s.Error(err) + s.Equal("issuer not found: wrong", err.Error()) + + _, err = a.GetIssuerByProfile("wrong_profile") + s.Error(err) + s.Equal("issuer not found for profile: wrong_profile", err.Error()) +} + +func (s *testSuite) TestIssuerSign() { + crypto := s.crypto.Default() + kr := csr.NewKeyRequest(crypto, "TestNewRoot"+guid.MustCreate(), "ECDSA", 256, csr.SigningKey) + rootReq := csr.CertificateRequest{ + CommonName: "[TEST] Trusty Root CA", + KeyRequest: kr, + } + rootPEM, _, rootKey, err := authority.NewRoot("ROOT", rootCfg, crypto, &rootReq) + s.Require().NoError(err) + + rootSigner, err := authority.NewSignerFromPEM(s.crypto, rootKey) + s.Require().NoError(err) + + cfg := &authority.IssuerConfig{ + AIA: &authority.AIAConfig{ + AiaURL: "https://localhost/v1/certs/${ISSUER_ID}.crt", + OcspURL: "https://localhost/v1/ocsp", + CrlURL: "https://localhost/v1/crl/${ISSUER_ID}.crl", + }, + Label: "TrustyRoot", + Profiles: map[string]*authority.CertProfile{ + "L1": { + Usage: []string{"cert sign", "crl sign"}, + Expiry: 1 * csr.OneYear, + OCSPNoCheck: true, + CAConstraint: authority.CAConstraint{ + IsCA: true, + MaxPathLen: 1, + }, + Policies: []csr.CertificatePolicy{ + { + ID: csr.OID{1, 2, 1000, 1}, + Qualifiers: []csr.CertificatePolicyQualifier{ + {Type: csr.CpsQualifierType, Value: "CPS"}, + {Type: csr.UserNoticeQualifierType, Value: "notice"}, + }, + }, + }, + AllowedExtensions: []csr.OID{ + {1, 3, 6, 1, 5, 5, 7, 1, 1}, + }, + }, + "RestrictedCA": { + Usage: []string{"cert sign", "crl sign"}, + Expiry: 1 * csr.OneYear, + Backdate: 0, + OCSPNoCheck: true, + CAConstraint: authority.CAConstraint{ + IsCA: true, + MaxPathLen: 0, + }, + AllowedNames: "^[Tt]rusty CA$", + AllowedDNS: "^trusty\\.com$", + AllowedEmail: "^ca@trusty\\.com$", + AllowedURI: "^spifee://trusty/.*$", + AllowedCSRFields: &csr.AllowedFields{ + Subject: true, + DNSNames: true, + IPAddresses: true, + EmailAddresses: true, + URIs: true, + }, + }, + "RestrictedServer": { + Usage: []string{"server auth", "signing", "key encipherment"}, + Expiry: 1 * csr.OneYear, + Backdate: 0, + AllowedNames: "trusty.com", + AllowedDNS: "^(www\\.)?trusty\\.com$", + AllowedEmail: "^ca@trusty\\.com$", + AllowedURI: "^spifee://trusty/.*$", + AllowedCSRFields: &csr.AllowedFields{ + Subject: true, + DNSNames: true, + IPAddresses: true, + EmailAddresses: true, + URIs: true, + }, + AllowedExtensions: []csr.OID{ + {1, 3, 6, 1, 5, 5, 7, 1, 1}, + }, + }, + "default": { + Usage: []string{"server auth", "signing", "key encipherment"}, + Expiry: 1 * csr.OneYear, + Backdate: 0, + AllowedNames: "trusty.com", + AllowedURI: "^spifee://trusty/.*$", + AllowedCSRFields: &csr.AllowedFields{ + Subject: true, + DNSNames: true, + URIs: true, + }, + AllowedExtensions: []csr.OID{ + {1, 2, 3}, + }, + }, + }, + } + + for name, profile := range cfg.Profiles { + s.NoError(profile.Validate(), "failed to validate %s profile", name) + } + + rootCA, err := authority.CreateIssuer(cfg, rootPEM, nil, nil, rootSigner) + s.Require().NoError(err) + + s.Run("default", func() { + req := csr.CertificateRequest{ + CommonName: "trusty.com", + SAN: []string{"www.trusty.com", "127.0.0.1", "server@trusty.com", "spifee://trusty/test"}, + KeyRequest: kr, + } + + csrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&req) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(csrPEM), + SAN: req.SAN, + Extensions: []csr.X509Extension{ + { + ID: csr.OID{1, 2, 3}, + Value: "0500", + }, + }, + } + + crt, _, err := rootCA.Sign(sreq) + s.Require().NoError(err) + s.Equal(req.CommonName, crt.Subject.CommonName) + s.Equal(rootReq.CommonName, crt.Issuer.CommonName) + s.False(crt.IsCA) + s.Equal(-1, crt.MaxPathLen) + s.NotEmpty(crt.IPAddresses) + s.NotEmpty(crt.EmailAddresses) + s.NotEmpty(crt.DNSNames) + s.NotEmpty(crt.URIs) + + // test unknown profile + sreq.Profile = "unknown" + _, _, err = rootCA.Sign(sreq) + s.Require().Error(err) + s.Equal("unsupported profile: unknown", err.Error()) + }) + + s.Run("Valid L1", func() { + caReq := csr.CertificateRequest{ + CommonName: "[TEST] Trusty Level 1 CA", + KeyRequest: kr, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + SAN: []string{"trusty@ekspand.com", "trusty.com", "127.0.0.1"}, + Request: string(caCsrPEM), + Profile: "L1", + Subject: &csr.X509Subject{ + CommonName: "Test L1 CA", + }, + } + + caCrt, _, err := rootCA.Sign(sreq) + s.Require().NoError(err) + s.Equal(sreq.Subject.CommonName, caCrt.Subject.CommonName) + s.Equal(rootReq.CommonName, caCrt.Issuer.CommonName) + s.True(caCrt.IsCA) + s.Equal(1, caCrt.MaxPathLen) + }) + + s.Run("RestrictedCA/NotAllowedCN", func() { + caReq := csr.CertificateRequest{ + CommonName: "[TEST] Trusty Level 2 CA", + KeyRequest: kr, + SAN: []string{"ca@trusty.com", "trusty.com", "127.0.0.1"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(caCsrPEM), + Profile: "RestrictedCA", + } + + _, _, err = rootCA.Sign(sreq) + s.Require().Error(err) + s.Equal("CommonName does not match allowed list: [TEST] Trusty Level 2 CA", err.Error()) + }) + + s.Run("RestrictedCA/NotAllowedDNS", func() { + caReq := csr.CertificateRequest{ + CommonName: "trusty CA", + KeyRequest: kr, + SAN: []string{"ca@trusty.com", "trustyca.com", "127.0.0.1"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(caCsrPEM), + Profile: "RestrictedCA", + } + + _, _, err = rootCA.Sign(sreq) + s.Require().Error(err) + s.Equal("DNS Name does not match allowed list: trustyca.com", err.Error()) + }) + + s.Run("RestrictedCA/NotAllowedURI", func() { + caReq := csr.CertificateRequest{ + CommonName: "trusty CA", + KeyRequest: kr, + SAN: []string{"ca@trusty.com", "127.0.0.1", "spifee://google.com/ca"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + SAN: caReq.SAN, + Request: string(caCsrPEM), + Profile: "RestrictedCA", + } + + _, _, err = rootCA.Sign(sreq) + s.Require().Error(err) + s.Equal("URI does not match allowed list: spifee://google.com/ca", err.Error()) + }) + + s.Run("RestrictedCA/NotAllowedEmail", func() { + caReq := csr.CertificateRequest{ + CommonName: "trusty CA", + KeyRequest: kr, + SAN: []string{"rootca@trusty.com", "trusty.com", "127.0.0.1"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(caCsrPEM), + Profile: "RestrictedCA", + } + + _, _, err = rootCA.Sign(sreq) + s.Require().Error(err) + s.Equal("Email does not match allowed list: rootca@trusty.com", err.Error()) + }) + + s.Run("RestrictedCA/Valid", func() { + caReq := csr.CertificateRequest{ + CommonName: "trusty CA", + KeyRequest: kr, + SAN: []string{"ca@trusty.com", "trusty.com", "127.0.0.1"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + caCsrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&caReq) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(caCsrPEM), + Profile: "RestrictedCA", + } + + caCrt, _, err := rootCA.Sign(sreq) + s.Require().NoError(err) + s.Equal(caReq.CommonName, caCrt.Subject.CommonName) + s.Equal(rootReq.CommonName, caCrt.Issuer.CommonName) + s.True(caCrt.IsCA) + s.Equal(0, caCrt.MaxPathLen) + s.True(caCrt.MaxPathLenZero) + // for CA, these are not set: + s.Empty(caCrt.DNSNames) + s.Empty(caCrt.EmailAddresses) + s.Empty(caCrt.IPAddresses) + }) + + s.Run("RestrictedServer/Valid", func() { + req := csr.CertificateRequest{ + CommonName: "trusty.com", + KeyRequest: kr, + SAN: []string{"ca@trusty.com", "www.trusty.com", "127.0.0.1"}, + Names: []csr.X509Name{ + { + O: "trusty", + C: "US", + }, + }, + } + + csrPEM, _, _, _, err := csr.NewProvider(crypto).CreateRequestAndExportKey(&req) + s.Require().NoError(err) + + sreq := csr.SignRequest{ + Request: string(csrPEM), + Profile: "RestrictedServer", + } + + crt, _, err := rootCA.Sign(sreq) + s.Require().NoError(err) + s.Equal(req.CommonName, crt.Subject.CommonName) + s.Equal(rootReq.CommonName, crt.Issuer.CommonName) + s.False(crt.IsCA) + s.Contains(crt.DNSNames, "www.trusty.com") + s.Contains(crt.EmailAddresses, "ca@trusty.com") + s.NotEmpty(crt.IPAddresses) + }) +} diff --git a/xpki/authority/config.go b/xpki/authority/config.go new file mode 100644 index 0000000..7c4e258 --- /dev/null +++ b/xpki/authority/config.go @@ -0,0 +1,447 @@ +package authority + +import ( + "crypto/x509" + "encoding/json" + "io/ioutil" + "regexp" + "strings" + "time" + + "github.com/go-phorce/dolly/algorithms/slices" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/jinzhu/copier" + "github.com/juju/errors" + "gopkg.in/yaml.v2" +) + +var ( + // DefaultCRLRenewal specifies default duration for CRL renewal + DefaultCRLRenewal = 7 * 24 * time.Hour // 7 days + // DefaultCRLExpiry specifies default duration for CRL expiry + DefaultCRLExpiry = 30 * 24 * time.Hour // 30 days + // DefaultOCSPExpiry specifies default for OCSP expiry + DefaultOCSPExpiry = 1 * 24 * time.Hour // 1 day +) + +// Config provides configuration for Certification Authority +type Config struct { + Authority *CAConfig `json:"authority,omitempty" yaml:"authority,omitempty"` + Profiles map[string]*CertProfile `json:"profiles" yaml:"profiles"` +} + +// CAConfig contains configuration info for CA +type CAConfig struct { + // DefaultAIA specifies default AIA configuration + DefaultAIA *AIAConfig `json:"default_aia,omitempty" yaml:"default_aia,omitempty"` + + // Issuers specifies the list of issuing authorities. + Issuers []IssuerConfig `json:"issuers,omitempty" yaml:"issuers,omitempty"` + + // PrivateRoots specifies the list of private Root Certs files. + PrivateRoots []string `json:"private_roots,omitempty" yaml:"private_roots,omitempty"` + + // PublicRoots specifies the list of public Root Certs files. + PublicRoots []string `json:"public_roots,omitempty" yaml:"public_roots,omitempty"` +} + +// IssuerConfig contains configuration info for the issuing certificate +type IssuerConfig struct { + // Disabled specifies if the certificate disabled to use + Disabled *bool `json:"disabled,omitempty" yaml:"disabled,omitempty"` + + // Label specifies Issuer's label + Label string `json:"label,omitempty" yaml:"label,omitempty"` + + // Type specifies type: tls|codesign|timestamp|ocsp|spiffe|trusty + Type string + + // CertFile specifies location of the cert + CertFile string `json:"cert,omitempty" yaml:"cert,omitempty"` + + // KeyFile specifies location of the key + KeyFile string `json:"key,omitempty" yaml:"key,omitempty"` + + // CABundleFile specifies location of the CA bundle file + CABundleFile string `json:"ca_bundle,omitempty" yaml:"ca_bundle,omitempty"` + + // RootBundleFile specifies location of the Root CA file + RootBundleFile string `json:"root_bundle,omitempty" yaml:"root_bundle,omitempty"` + + // AIA specifies AIA configuration + AIA *AIAConfig `json:"aia,omitempty" yaml:"aia,omitempty"` + + // Profiles are populated after loading + Profiles map[string]*CertProfile `json:"-" yaml:"-"` +} + +// AIAConfig contains AIA configuration info +type AIAConfig struct { + // AiaURL specifies a template for AIA URL. + // The ${ISSUER_ID} variable will be replaced with a Subject Key Identifier of the issuer. + AiaURL string `json:"issuer_url" yaml:"issuer_url"` + + // OcspURL specifies a template for OCSP URL. + // The ${ISSUER_ID} variable will be replaced with a Subject Key Identifier of the issuer. + OcspURL string `json:"ocsp_url" yaml:"ocsp_url"` + + // DefaultOcspURL specifies a template for CRL URL. + // The ${ISSUER_ID} variable will be replaced with a Subject Key Identifier of the issuer. + CrlURL string `json:"crl_url" yaml:"crl_url"` + + // CRLExpiry specifies value in 72h format for duration of CRL next update time + CRLExpiry time.Duration `json:"crl_expiry,omitempty" yaml:"crl_expiry,omitempty"` + + // OCSPExpiry specifies value in 8h format for duration of OCSP next update time + OCSPExpiry time.Duration `json:"ocsp_expiry,omitempty" yaml:"ocsp_expiry,omitempty"` + + // CRLRenewal specifies value in 8h format for duration of CRL renewal before next update time + CRLRenewal time.Duration `json:"crl_renewal,omitempty" yaml:"crl_renewal,omitempty"` +} + +// Copy returns new copy +func (c *Config) Copy() *Config { + d := new(Config) + copier.Copy(d, c) + return d +} + +// Copy returns new copy +func (c *IssuerConfig) Copy() *IssuerConfig { + d := new(IssuerConfig) + copier.Copy(d, c) + return d +} + +// Copy returns new copy +func (c *AIAConfig) Copy() *AIAConfig { + return &AIAConfig{ + c.AiaURL, + c.OcspURL, + c.CrlURL, + c.CRLExpiry, + c.OCSPExpiry, + c.CRLRenewal, + } +} + +// GetDisabled specifies if the certificate disabled to use +func (c *IssuerConfig) GetDisabled() bool { + return c.Disabled != nil && *c.Disabled +} + +// GetCRLExpiry specifies value in 72h format for duration of CRL next update time +func (c *AIAConfig) GetCRLExpiry() time.Duration { + if c != nil && c.CRLExpiry > 0 { + return c.CRLExpiry + } + return DefaultCRLExpiry +} + +// GetOCSPExpiry specifies value in 8h format for duration of OCSP next update time +func (c *AIAConfig) GetOCSPExpiry() time.Duration { + if c != nil && c.OCSPExpiry > 0 { + return c.OCSPExpiry + } + return DefaultOCSPExpiry +} + +// GetCRLRenewal specifies value in 8h format for duration of CRL renewal before next update time +func (c *AIAConfig) GetCRLRenewal() time.Duration { + if c != nil && c.CRLRenewal > 0 { + return c.CRLRenewal + } + return DefaultCRLRenewal +} + +// CertProfile provides certificate profile +type CertProfile struct { + Description string `json:"description" yaml:"description"` + + // Usage provides a list key usages + Usage []string `json:"usages" yaml:"usages"` + + CAConstraint CAConstraint `json:"ca_constraint" yaml:"ca_constraint"` + OCSPNoCheck bool `json:"ocsp_no_check" yaml:"ocsp_no_check"` + + Expiry csr.Duration `json:"expiry" yaml:"expiry"` + Backdate csr.Duration `json:"backdate" yaml:"backdate"` + + AllowedExtensions []csr.OID `json:"allowed_extensions" yaml:"allowed_extensions"` + + // AllowedNames specifies a RegExp to check for allowed names. + // If not provided, then all values are allowed + AllowedNames string `json:"allowed_names" yaml:"allowed_names"` + + // AllowedDNS specifies a RegExp to check for allowed DNS. + // If not provided, then all values are allowed + AllowedDNS string `json:"allowed_dns" yaml:"allowed_dns"` + + // AllowedEmail specifies a RegExp to check for allowed email. + // If not provided, then all values are allowed + AllowedEmail string `json:"allowed_email" yaml:"allowed_email"` + + // AllowedURI specifies a RegExp to check for allowed URI. + // If not provided, then all values are allowed + AllowedURI string `json:"allowed_uri" yaml:"allowed_uri"` + + // AllowedFields provides booleans for fields in the CSR. + // If a AllowedFields is not present in a CertProfile, + // all of these fields may be copied from the CSR into the signed certificate. + // If a AllowedFields *is* present in a CertProfile, + // only those fields with a `true` value in the AllowedFields may + // be copied from the CSR to the signed certificate. + // Note that some of these fields, like Subject, can be provided or + // partially provided through the API. + // Since API clients are expected to be trusted, but CSRs are not, fields + // provided through the API are not subject to validation through this + // mechanism. + AllowedCSRFields *csr.AllowedFields `json:"allowed_fields" yaml:"allowed_fields"` + + Policies []csr.CertificatePolicy `json:"policies" yaml:"policies"` + + IssuerLabel string `json:"issuer_label" yaml:"issuer_label"` + AllowedRoles []string `json:"allowed_roles" yaml:"allowed_roles"` + DeniedRoles []string `json:"denied_roles" yaml:"denied_roles"` + + AllowedNamesRegex *regexp.Regexp `json:"-" yaml:"-"` + AllowedDNSRegex *regexp.Regexp `json:"-" yaml:"-"` + AllowedEmailRegex *regexp.Regexp `json:"-" yaml:"-"` + AllowedURIRegex *regexp.Regexp `json:"-" yaml:"-"` +} + +// CAConstraint specifies various CA constraints on the signed certificate. +// CAConstraint would verify against (and override) the CA +// extensions in the given CSR. +type CAConstraint struct { + IsCA bool `json:"is_ca" yaml:"is_ca"` + MaxPathLen int `json:"max_path_len" yaml:"max_path_len"` +} + +// Copy returns new copy +func (p *CertProfile) Copy() *CertProfile { + d := new(CertProfile) + copier.Copy(d, p) + return d +} + +// AllowedExtensionsStrings returns slice of strings +func (p *CertProfile) AllowedExtensionsStrings() []string { + list := make([]string, len(p.AllowedExtensions)) + for i, o := range p.AllowedExtensions { + list[i] = o.String() + } + return list +} + +// IsAllowed returns true, if a role is allowed to request this profile +func (p *CertProfile) IsAllowed(role string) bool { + if len(p.DeniedRoles) > 0 && (slices.ContainsString(p.DeniedRoles, role) || slices.ContainsString(p.DeniedRoles, "*")) { + return false + } + if len(p.AllowedRoles) > 0 && (slices.ContainsString(p.AllowedRoles, role) || slices.ContainsString(p.AllowedRoles, "*")) { + return true + } + return true +} + +// DefaultCertProfile returns a default configuration +// for a certificate profile, specifying basic key +// usage and a 1 year expiration time. +// The key usages chosen are: +// signing, key encipherment, client auth and server auth. +func DefaultCertProfile() *CertProfile { + return &CertProfile{ + Description: "default profile with Server and Client auth", + Usage: []string{"signing", "key encipherment", "server auth", "client auth"}, + Expiry: csr.Duration(8760 * time.Hour), + Backdate: csr.Duration(10 * time.Minute), + } +} + +// LoadConfig loads the configuration file stored at the path +// and returns the configuration. +func LoadConfig(path string) (*Config, error) { + if path == "" { + return nil, errors.New("invalid path") + } + + body, err := ioutil.ReadFile(path) + if err != nil { + return nil, errors.Annotate(err, "unable to read configuration file") + } + + var cfg = new(Config) + if strings.HasSuffix(path, ".json") { + err = json.Unmarshal(body, cfg) + } else { + err = yaml.Unmarshal(body, cfg) + } + + if err != nil { + return nil, errors.Annotate(err, "failed to unmarshal configuration") + } + + if len(cfg.Profiles) == 0 { + return nil, errors.New("no \"profiles\" configuration present") + } + + if cfg.Profiles["default"] == nil { + logger.Infof("reason=no_default_profile") + cfg.Profiles["default"] = DefaultCertProfile() + } + + if cfg.Authority != nil && cfg.Authority.DefaultAIA != nil { + for i := range cfg.Authority.Issuers { + iss := &cfg.Authority.Issuers[i] + if iss.AIA == nil { + iss.AIA = cfg.Authority.DefaultAIA.Copy() + } else { + if iss.AIA.AiaURL == "" { + iss.AIA.AiaURL = cfg.Authority.DefaultAIA.AiaURL + } + if iss.AIA.CrlURL == "" { + iss.AIA.CrlURL = cfg.Authority.DefaultAIA.CrlURL + } + if iss.AIA.OcspURL == "" { + iss.AIA.OcspURL = cfg.Authority.DefaultAIA.OcspURL + } + if iss.AIA.CRLExpiry == 0 { + iss.AIA.CRLExpiry = cfg.Authority.DefaultAIA.GetCRLExpiry() + } + if iss.AIA.CRLRenewal == 0 { + iss.AIA.CRLRenewal = cfg.Authority.DefaultAIA.GetCRLRenewal() + } + if iss.AIA.OCSPExpiry == 0 { + iss.AIA.OCSPExpiry = cfg.Authority.DefaultAIA.GetOCSPExpiry() + } + } + + iss.Profiles = make(map[string]*CertProfile) + for name, profile := range cfg.Profiles { + if profile.IssuerLabel == iss.Label || + (profile.IssuerLabel == "" && len(cfg.Authority.Issuers) == 1) { + iss.Profiles[name] = profile + } + } + } + } + + if err = cfg.Validate(); err != nil { + return nil, errors.Annotate(err, "invalid configuration") + } + + return cfg, nil +} + +// DefaultCertProfile returns default CertProfile +func (c *Config) DefaultCertProfile() *CertProfile { + return c.Profiles["default"] +} + +// Validate returns an error if the profile is invalid +func (p *CertProfile) Validate() error { + if p.Expiry == 0 { + return errors.New("no expiry set") + } + + if len(p.Usage) == 0 { + return errors.New("no usages specified") + } else if _, _, unk := p.Usages(); len(unk) > 0 { + return errors.Errorf("unknown usage: %s", strings.Join(unk, ",")) + } + + for _, policy := range p.Policies { + for _, qualifier := range policy.Qualifiers { + if qualifier.Type != "" && + qualifier.Type != csr.UserNoticeQualifierType && + qualifier.Type != csr.CpsQualifierType { + return errors.New("invalid policy qualifier type: " + qualifier.Type) + } + } + } + + if p.AllowedNames != "" && p.AllowedNamesRegex == nil { + rule, err := regexp.Compile(p.AllowedNames) + if err != nil { + return errors.Annotate(err, "failed to compile AllowedNames") + } + p.AllowedNamesRegex = rule + } + if p.AllowedDNS != "" && p.AllowedDNSRegex == nil { + rule, err := regexp.Compile(p.AllowedDNS) + if err != nil { + return errors.Annotate(err, "failed to compile AllowedDNS") + } + p.AllowedDNSRegex = rule + } + if p.AllowedEmail != "" && p.AllowedEmailRegex == nil { + rule, err := regexp.Compile(p.AllowedEmail) + if err != nil { + return errors.Annotate(err, "failed to compile AllowedEmail") + } + p.AllowedEmailRegex = rule + } + if p.AllowedURI != "" && p.AllowedURIRegex == nil { + rule, err := regexp.Compile(p.AllowedURI) + if err != nil { + return errors.Annotate(err, "failed to compile AllowedURI") + } + p.AllowedURIRegex = rule + } + + return nil +} + +// IsAllowedExtention returns true of the extension is allowed +func (p *CertProfile) IsAllowedExtention(oid csr.OID) bool { + for _, allowed := range p.AllowedExtensions { + if allowed.Equal(oid) { + return true + } + } + return false +} + +// Validate returns an error if the configuration is invalid +func (c *Config) Validate() error { + var err error + + issuers := map[string]bool{} + if c.Authority != nil { + for i := range c.Authority.Issuers { + iss := &c.Authority.Issuers[i] + issuers[iss.Label] = true + } + } + + for name, profile := range c.Profiles { + err = profile.Validate() + if err != nil { + return errors.Annotatef(err, "invalid %s profile", name) + } + if profile.IssuerLabel != "" { + if !issuers[profile.IssuerLabel] { + return errors.Annotatef(err, "%s issuer not found for %s profile", profile.IssuerLabel, name) + } + } + } + + return nil +} + +// Usages parses the list of key uses in the profile, translating them +// to a list of X.509 key usages and extended key usages. +// The unknown uses are collected into a slice that is also returned. +func (p *CertProfile) Usages() (ku x509.KeyUsage, eku []x509.ExtKeyUsage, unk []string) { + for _, keyUse := range p.Usage { + if kuse, ok := csr.KeyUsage[keyUse]; ok { + ku |= kuse + } else if ekuse, ok := csr.ExtKeyUsage[keyUse]; ok { + eku = append(eku, ekuse) + } else { + unk = append(unk, keyUse) + } + } + return +} diff --git a/xpki/authority/config_test.go b/xpki/authority/config_test.go new file mode 100644 index 0000000..75ce887 --- /dev/null +++ b/xpki/authority/config_test.go @@ -0,0 +1,192 @@ +package authority_test + +import ( + "testing" + "time" + + "github.com/go-phorce/dolly/xpki/authority" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const projFolder = "../" + +func TestDefaultCertProfile(t *testing.T) { + def := authority.DefaultCertProfile().Copy() + require.NotNil(t, def) + + def.AllowedExtensions = []csr.OID{{1, 2, 3, 4, 5, 6, 8}} + assert.Equal(t, time.Duration(10*time.Minute), def.Backdate.TimeDuration()) + assert.Equal(t, time.Duration(8760*time.Hour), def.Expiry.TimeDuration()) + assert.Equal(t, "default profile with Server and Client auth", def.Description) + require.NotEmpty(t, def.Usage) + assert.Contains(t, def.Usage, "signing") + assert.Contains(t, def.Usage, "key encipherment") + assert.Contains(t, def.Usage, "server auth") + assert.Contains(t, def.Usage, "client auth") + assert.NoError(t, def.Validate()) + assert.False(t, def.IsAllowedExtention(csr.OID{1, 2, 3, 4, 5, 6, 7})) + assert.True(t, def.IsAllowedExtention(csr.OID{1, 2, 3, 4, 5, 6, 8})) + assert.NotEmpty(t, def.AllowedExtensionsStrings()) +} + +func TestLoadInvalidConfigFile(t *testing.T) { + tcases := []struct { + file string + err string + }{ + {"", "invalid path"}, + {"testdata/no_such_file", "unable to read configuration file: open testdata/no_such_file: no such file or directory"}, + {"testdata/invalid_default.json", "failed to unmarshal configuration: time: invalid duration \"invalid_expiry\""}, + {"testdata/invalid_empty.json", "no \"profiles\" configuration present"}, + {"testdata/invalid_server.json", "invalid configuration: invalid server profile: unknown usage: encipherment"}, + {"testdata/invalid_noexpiry.json", "invalid configuration: invalid noexpiry_profile profile: no expiry set"}, + {"testdata/invalid_nousage.json", "invalid configuration: invalid no_usage_profile profile: no usages specified"}, + {"testdata/invalid_allowedname.json", "invalid configuration: invalid withregex profile: failed to compile AllowedNames: error parsing regexp: missing closing ]: `[}`"}, + {"testdata/invalid_dns.json", "invalid configuration: invalid withregex profile: failed to compile AllowedDNS: error parsing regexp: missing closing ]: `[}`"}, + {"testdata/invalid_uri.json", "invalid configuration: invalid withregex profile: failed to compile AllowedURI: error parsing regexp: missing closing ]: `[}`"}, + {"testdata/invalid_email.json", "invalid configuration: invalid withregex profile: failed to compile AllowedEmail: error parsing regexp: missing closing ]: `[}`"}, + {"testdata/invalid_qualifier.json", "invalid configuration: invalid with-qt profile: invalid policy qualifier type: qt-type"}, + } + for _, tc := range tcases { + t.Run(tc.file, func(t *testing.T) { + _, err := authority.LoadConfig(tc.file) + require.Error(t, err) + assert.Equal(t, tc.err, err.Error()) + }) + } + +} + +func TestLoadConfig(t *testing.T) { + _, err := authority.LoadConfig("") + require.Error(t, err) + assert.Equal(t, "invalid path", err.Error()) + + _, err = authority.LoadConfig("not_found") + require.Error(t, err) + assert.Equal(t, "unable to read configuration file: open not_found: no such file or directory", err.Error()) + + cfg, err := authority.LoadConfig("testdata/ca-config.dev.json") + require.NoError(t, err) + require.NotEmpty(t, cfg.Profiles) + + cfg2 := cfg.Copy() + assert.Equal(t, cfg, cfg2) + + def := cfg.DefaultCertProfile() + require.NotNil(t, def) + assert.Equal(t, time.Duration(30*time.Minute), def.Backdate.TimeDuration()) + assert.Equal(t, time.Duration(168*time.Hour), def.Expiry.TimeDuration()) + + files := []string{ + "testdata/ca-config.dev.json", + "testdata/ca-config.bootstrap.json", + "testdata/ca-config.dev.yaml", + "testdata/ca-config.bootstrap.yaml", + } + for _, path := range files { + cfg, err := authority.LoadConfig(path) + require.NoError(t, err, "failed to parse: %s", path) + require.NotEmpty(t, cfg.Profiles) + } +} + +func TestCertProfile(t *testing.T) { + p := authority.CertProfile{ + Expiry: csr.OneYear, + Usage: []string{"signing", "any"}, + AllowedNames: "trusty*", + AllowedDNS: "^(www\\.)?trusty\\.com$", + AllowedEmail: "^ca@trusty\\.com$", + AllowedURI: "^spifee://trysty/.*$", + AllowedExtensions: []csr.OID{ + {1, 1000, 1, 1}, + {1, 1000, 1, 3}, + }, + } + assert.NoError(t, p.Validate()) + assert.True(t, p.IsAllowedExtention(csr.OID{1, 1000, 1, 3})) + assert.False(t, p.IsAllowedExtention(csr.OID{1, 1000, 1, 3, 1})) +} + +func TestDefaultAuthority(t *testing.T) { + a := &authority.CAConfig{} + assert.Equal(t, authority.DefaultCRLExpiry, a.DefaultAIA.GetCRLExpiry()) + assert.Equal(t, authority.DefaultOCSPExpiry, a.DefaultAIA.GetOCSPExpiry()) + assert.Equal(t, authority.DefaultCRLRenewal, a.DefaultAIA.GetCRLRenewal()) + + d := 1 * time.Hour + a = &authority.CAConfig{ + DefaultAIA: &authority.AIAConfig{ + CRLExpiry: d, + OCSPExpiry: d, + CRLRenewal: d, + }, + } + assert.Equal(t, time.Duration(d), a.DefaultAIA.GetCRLExpiry()) + assert.Equal(t, time.Duration(d), a.DefaultAIA.GetOCSPExpiry()) + assert.Equal(t, time.Duration(d), a.DefaultAIA.GetCRLRenewal()) +} + +func TestProfilePolicyIsAllowed(t *testing.T) { + emptyPolicy := &authority.CertProfile{} + policy1 := &authority.CertProfile{ + IssuerLabel: "issuer1", + AllowedRoles: []string{"allowed1"}, + DeniedRoles: []string{"denied1"}, + } + policy2 := &authority.CertProfile{ + IssuerLabel: "issuer2", + AllowedRoles: []string{"*"}, + DeniedRoles: []string{"denied1"}, + } + policy3 := &authority.CertProfile{ + IssuerLabel: "issuer3", + AllowedRoles: []string{"*"}, + DeniedRoles: []string{"*"}, + } + + tcases := []struct { + policy *authority.CertProfile + role string + allowed bool + }{ + { + policy: emptyPolicy, + role: "roles1", + allowed: true, + }, + { + policy: emptyPolicy, + role: "", + allowed: true, + }, + { + policy: policy1, + role: "allowed1", + allowed: true, + }, + { + policy: policy1, + role: "denied1", + allowed: false, + }, + { + policy: policy2, + role: "any", + allowed: true, + }, + { + policy: policy3, + role: "any", + allowed: false, + }, + } + + for _, tc := range tcases { + assert.Equal(t, tc.allowed, tc.policy.IsAllowed(tc.role), "[%s] %s: Allowed->%v, Denied->%v", + tc.policy.IssuerLabel, tc.role, tc.policy.AllowedRoles, tc.policy.DeniedRoles) + } +} diff --git a/xpki/authority/doc.go b/xpki/authority/doc.go new file mode 100644 index 0000000..fb31284 --- /dev/null +++ b/xpki/authority/doc.go @@ -0,0 +1,2 @@ +// Package authority contains the Certification Authority. +package authority diff --git a/xpki/authority/extensions.go b/xpki/authority/extensions.go new file mode 100644 index 0000000..a1400fe --- /dev/null +++ b/xpki/authority/extensions.go @@ -0,0 +1,120 @@ +package authority + +import ( + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + + "github.com/go-phorce/dolly/xpki/csr" + "github.com/juju/errors" +) + +type policyInformation struct { + PolicyIdentifier asn1.ObjectIdentifier + Qualifiers []interface{} `asn1:"tag:optional,omitempty"` +} + +type cpsPolicyQualifier struct { + PolicyQualifierID asn1.ObjectIdentifier + Qualifier string `asn1:"tag:optional,ia5"` +} + +type userNotice struct { + ExplicitText string `asn1:"tag:optional,utf8"` +} +type userNoticePolicyQualifier struct { + PolicyQualifierID asn1.ObjectIdentifier + Qualifier userNotice +} + +var ( + // Per https://tools.ietf.org/html/rfc3280.html#page-106, this represents: + // iso(1) identified-organization(3) dod(6) internet(1) security(5) + // mechanisms(5) pkix(7) id-qt(2) id-qt-cps(1) + iDQTCertificationPracticeStatement = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 2, 1} + // iso(1) identified-organization(3) dod(6) internet(1) security(5) + // mechanisms(5) pkix(7) id-qt(2) id-qt-unotice(2) + iDQTUserNotice = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 2, 2} + + // CTPoisonOID is the object ID of the critical poison extension for precertificates + // https://tools.ietf.org/html/rfc6962#page-9 + CTPoisonOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} + + // SCTListOID is the object ID for the Signed Certificate Timestamp certificate extension + // https://tools.ietf.org/html/rfc6962#page-14 + SCTListOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} +) + +// addPolicies adds Certificate Policies and optional Policy Qualifiers to a +// certificate, based on the input config. Go's x509 library allows setting +// Certificate Policies easily, but does not support nested Policy Qualifiers +// under those policies. So we need to construct the ASN.1 structure ourselves. +func addPolicies(template *x509.Certificate, policies []csr.CertificatePolicy) error { + asn1PolicyList := []policyInformation{} + + for _, policy := range policies { + pi := policyInformation{ + // The PolicyIdentifier is an OID assigned to a given issuer. + PolicyIdentifier: asn1.ObjectIdentifier(policy.ID), + } + for _, qualifier := range policy.Qualifiers { + switch qualifier.Type { + case "id-qt-unotice": + pi.Qualifiers = append(pi.Qualifiers, + userNoticePolicyQualifier{ + PolicyQualifierID: iDQTUserNotice, + Qualifier: userNotice{ + ExplicitText: qualifier.Value, + }, + }) + case "id-qt-cps": + pi.Qualifiers = append(pi.Qualifiers, + cpsPolicyQualifier{ + PolicyQualifierID: iDQTCertificationPracticeStatement, + Qualifier: qualifier.Value, + }) + default: + return errors.New("Invalid qualifier type in Policies " + qualifier.Type) + } + } + asn1PolicyList = append(asn1PolicyList, pi) + } + + asn1Bytes, err := asn1.Marshal(asn1PolicyList) + if err != nil { + return errors.Trace(err) + } + + template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{ + Id: asn1.ObjectIdentifier{2, 5, 29, 32}, + Critical: false, + Value: asn1Bytes, + }) + return nil +} + +// computeSKI derives an SKI from the certificate's public key in a +// standard manner. This is done by computing the SHA-1 digest of the +// SubjectPublicKeyInfo component of the certificate. +func computeSKI(template *x509.Certificate) ([]byte, error) { + pub := template.PublicKey + encodedPub, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, errors.Trace(err) + } + + var subPKI subjectPublicKeyInfo + _, err = asn1.Unmarshal(encodedPub, &subPKI) + if err != nil { + return nil, errors.Trace(err) + } + + pubHash := sha1.Sum(subPKI.SubjectPublicKey.Bytes) + return pubHash[:], nil +} + +type subjectPublicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString +} diff --git a/xpki/authority/issuer.go b/xpki/authority/issuer.go new file mode 100644 index 0000000..95c6023 --- /dev/null +++ b/xpki/authority/issuer.go @@ -0,0 +1,510 @@ +package authority + +import ( + "crypto" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "io" + "io/ioutil" + "math/big" + "strings" + "time" + + "github.com/go-phorce/dolly/xpki/certutil" + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/juju/errors" +) + +var ( + supportedKeyHash = []crypto.Hash{crypto.SHA1, crypto.SHA256, crypto.SHA384, crypto.SHA512} +) + +// Issuer of certificates +type Issuer struct { + cfg IssuerConfig + label string + skid string // Subject Key ID + signer crypto.Signer + sigAlgo x509.SignatureAlgorithm + bundle *certutil.Bundle + crlRenewal time.Duration + crlExpiry time.Duration + ocspExpiry time.Duration + crlURL string + aiaURL string + ocspURL string + + // cabundlePEM contains PEM encoded certs for the issuer, + // this bundle includes Issuing cert itself and its parents. + cabundlePEM string + + keyHash map[crypto.Hash][]byte + nameHash map[crypto.Hash][]byte +} + +// Bundle returns certificates bundle +func (ca *Issuer) Bundle() *certutil.Bundle { + return ca.bundle +} + +// PEM returns PEM encoded certs for the issuer +func (ca *Issuer) PEM() string { + return ca.cabundlePEM +} + +// CrlURL returns CRL DP URL +func (ca *Issuer) CrlURL() string { + return ca.crlURL +} + +// OcspURL returns OCSP URL +func (ca *Issuer) OcspURL() string { + return ca.ocspURL +} + +// AiaURL returns AIA URL +func (ca *Issuer) AiaURL() string { + return ca.aiaURL +} + +// Label returns label of the issuer +func (ca *Issuer) Label() string { + return ca.label +} + +// SubjectKID returns Subject Key ID +func (ca *Issuer) SubjectKID() string { + return ca.skid +} + +// Signer returns crypto.Signer +func (ca *Issuer) Signer() crypto.Signer { + return ca.signer +} + +// KeyHash returns key hash +func (ca *Issuer) KeyHash(h crypto.Hash) []byte { + return ca.keyHash[h] +} + +// CrlRenewal is duration for CRL renewal interval +func (ca *Issuer) CrlRenewal() time.Duration { + return ca.crlRenewal +} + +// CrlExpiry is duration for CRL next update interval +func (ca *Issuer) CrlExpiry() time.Duration { + return ca.crlExpiry +} + +// OcspExpiry is duration for OCSP next update interval +func (ca *Issuer) OcspExpiry() time.Duration { + return ca.ocspExpiry +} + +// Profile returns CertProfile +func (ca *Issuer) Profile(name string) *CertProfile { + return ca.cfg.Profiles[name] +} + +// NewIssuer creates Issuer from provided configuration +func NewIssuer(cfg *IssuerConfig, prov *cryptoprov.Crypto) (*Issuer, error) { + // ensure that signer can be created before the key is generated + cryptoSigner, err := NewSignerFromFromFile( + prov, + cfg.KeyFile) + if err != nil { + return nil, errors.Annotatef(err, "unable to create signer") + } + + // Build the bundle and register the CA cert + var intCAbytes, rootBytes []byte + if cfg.CABundleFile != "" { + intCAbytes, err = ioutil.ReadFile(cfg.CABundleFile) + if err != nil { + return nil, errors.Annotate(err, "failed to load ca-bundle") + } + } + + if cfg.RootBundleFile != "" { + rootBytes, err = ioutil.ReadFile(cfg.RootBundleFile) + if err != nil { + return nil, errors.Annotatef(err, "failed to load root-bundle") + } + } + + certBytes, err := ioutil.ReadFile(cfg.CertFile) + if err != nil { + return nil, errors.Annotatef(err, "failed to load cert") + } + issuer, err := CreateIssuer(cfg, certBytes, intCAbytes, rootBytes, cryptoSigner) + if err != nil { + return nil, errors.Trace(err) + } + + return issuer, nil +} + +// CreateIssuer returns Issuer created directly from crypto.Signer, +// this method is mostly used for testing +func CreateIssuer(cfg *IssuerConfig, certBytes, intCAbytes, rootBytes []byte, signer crypto.Signer) (*Issuer, error) { + label := cfg.Label + bundle, status, err := certutil.VerifyBundleFromPEM(certBytes, intCAbytes, rootBytes) + if err != nil { + return nil, errors.Annotate(err, "failed to create signing CA cert bundle") + } + if status.IsUntrusted() { + return nil, errors.Annotatef(err, "bundle is invalid: label=%s, cn=%q, expiresAt=%q, expiringSKU=[%v], untrusted=[%v]", + label, + bundle.Subject.CommonName, + bundle.Expires.Format(time.RFC3339), + strings.Join(status.ExpiringSKIs, ","), + strings.Join(status.Untrusted, ","), + ) + } + + var crlRenewal, crlExpiry, ocspExpiry time.Duration + var crl, aia, ocsp string + if cfg.AIA != nil { + crl = strings.Replace(cfg.AIA.CrlURL, "${ISSUER_ID}", bundle.SubjectID, -1) + aia = strings.Replace(cfg.AIA.AiaURL, "${ISSUER_ID}", bundle.SubjectID, -1) + ocsp = strings.Replace(cfg.AIA.OcspURL, "${ISSUER_ID}", bundle.SubjectID, -1) + crlRenewal = cfg.AIA.CRLRenewal + crlExpiry = cfg.AIA.CRLExpiry + ocspExpiry = cfg.AIA.OCSPExpiry + } + + keyHash := make(map[crypto.Hash][]byte) + nameHash := make(map[crypto.Hash][]byte) + + for _, h := range supportedKeyHash { + // OCSP requires Hash of the Key without Tag: + /// issuerKeyHash is the hash of the issuer's public key. The hash + // shall be calculated over the value (excluding tag and length) of + // the subject public key field in the issuer's certificate. + var publicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString + } + _, err = asn1.Unmarshal(bundle.Cert.RawSubjectPublicKeyInfo, &publicKeyInfo) + if err != nil { + return nil, errors.Annotatef(err, "failed to decode SubjectPublicKeyInfo") + } + + keyHash[h] = certutil.Digest(h, publicKeyInfo.PublicKey.RightAlign()) + nameHash[h] = certutil.Digest(h, bundle.Cert.RawSubject) + + logger.Infof("label=%s, alg=%s, keyHash=%s, nameHash=%s", + label, certutil.HashAlgoToStr(h), hex.EncodeToString(keyHash[h]), hex.EncodeToString(nameHash[h])) + } + + cabundlePEM := strings.TrimSpace(bundle.CertPEM) + if bundle.CACertsPEM != "" { + cabundlePEM = cabundlePEM + "\n" + strings.TrimSpace(bundle.CACertsPEM) + } + + return &Issuer{ + cfg: *cfg, + skid: certutil.GetSubjectKeyID(bundle.Cert), + signer: signer, + sigAlgo: csr.DefaultSigAlgo(signer), + bundle: bundle, + label: label, + crlURL: crl, + aiaURL: aia, + ocspURL: ocsp, + cabundlePEM: cabundlePEM, + keyHash: keyHash, + nameHash: nameHash, + crlRenewal: crlRenewal, + crlExpiry: crlExpiry, + ocspExpiry: ocspExpiry, + }, nil +} + +// Sign signs a new certificate based on the PEM-encoded +// certificate request with the specified profile. +func (ca *Issuer) Sign(req csr.SignRequest) (*x509.Certificate, []byte, error) { + profileName := req.Profile + if profileName == "" { + profileName = "default" + } + profile := ca.cfg.Profiles[profileName] + if profile == nil { + return nil, nil, errors.New("unsupported profile: " + profileName) + } + + csrTemplate, err := csr.ParsePEM([]byte(req.Request)) + if err != nil { + return nil, nil, errors.Trace(err) + } + + csrTemplate.SignatureAlgorithm = ca.sigAlgo + + // Copy out only the fields from the CSR authorized by policy. + safeTemplate := x509.Certificate{} + // If the profile contains no explicit whitelist, assume that all fields + // should be copied from the CSR. + if profile.AllowedCSRFields == nil { + safeTemplate = *csrTemplate + } else { + if profile.AllowedCSRFields.Subject { + safeTemplate.Subject = csrTemplate.Subject + } + if profile.AllowedCSRFields.DNSNames { + safeTemplate.DNSNames = csrTemplate.DNSNames + } + if profile.AllowedCSRFields.IPAddresses { + safeTemplate.IPAddresses = csrTemplate.IPAddresses + } + if profile.AllowedCSRFields.URIs { + safeTemplate.URIs = csrTemplate.URIs + } + if profile.AllowedCSRFields.EmailAddresses { + safeTemplate.EmailAddresses = csrTemplate.EmailAddresses + } + safeTemplate.PublicKeyAlgorithm = csrTemplate.PublicKeyAlgorithm + safeTemplate.PublicKey = csrTemplate.PublicKey + safeTemplate.SignatureAlgorithm = csrTemplate.SignatureAlgorithm + } + + /* + isSelfSign := ca.bundle == nil + if safeTemplate.IsCA { + if !profile.CAConstraint.IsCA { + return nil, nil, errors.New("the policy disallows issuing CA certificate") + } + + if !isSelfSign { + caCert := ca.bundle.Cert + if caCert.MaxPathLen > 0 { + if safeTemplate.MaxPathLen >= caCert.MaxPathLen { + return nil, nil, errors.New("the issuer disallows CA MaxPathLen extending") + } + } else if caCert.MaxPathLen == 0 && caCert.MaxPathLenZero { + // signer has pathlen of 0, do not sign more intermediate CAs + return nil, nil, errors.New("the issuer disallows issuing CA certificate") + } + } + } + */ + + csr.SetSAN(&safeTemplate, req.SAN) + safeTemplate.Subject = csr.PopulateName(req.Subject, safeTemplate.Subject) + + // If there is a whitelist, ensure that both the Common Name, SAN DNSNames and Emails match + if profile.AllowedNamesRegex != nil && safeTemplate.Subject.CommonName != "" { + if !profile.AllowedNamesRegex.Match([]byte(safeTemplate.Subject.CommonName)) { + return nil, nil, errors.New("CommonName does not match allowed list: " + safeTemplate.Subject.CommonName) + } + } + if profile.AllowedDNSRegex != nil { + for _, name := range safeTemplate.DNSNames { + if !profile.AllowedDNSRegex.Match([]byte(name)) { + return nil, nil, errors.New("DNS Name does not match allowed list: " + name) + } + } + } + if profile.AllowedEmailRegex != nil { + for _, name := range safeTemplate.EmailAddresses { + if !profile.AllowedEmailRegex.Match([]byte(name)) { + return nil, nil, errors.New("Email does not match allowed list: " + name) + } + } + } + if profile.AllowedURIRegex != nil { + for _, u := range safeTemplate.URIs { + uri := u.String() + if !profile.AllowedURIRegex.Match([]byte(uri)) { + return nil, nil, errors.New("URI does not match allowed list: " + uri) + } + } + } + + { + // RFC 5280 4.1.2.2: + // Certificate users MUST be able to handle serialNumber + // values up to 20 octets. Conforming CAs MUST NOT use + // serialNumber values longer than 20 octets. + serialNumber := make([]byte, 20) + _, err = io.ReadFull(rand.Reader, serialNumber) + if err != nil { + return nil, nil, errors.Annotatef(err, "failed to generate serial number") + } + + // SetBytes interprets buf as the bytes of a big-endian + // unsigned integer. The leading byte should be masked + // off to ensure it isn't negative. + serialNumber[0] &= 0x7F + + safeTemplate.SerialNumber = new(big.Int).SetBytes(serialNumber) + } + + if len(req.Extensions) > 0 { + for _, ext := range req.Extensions { + if !profile.IsAllowedExtention(ext.ID) { + return nil, nil, errors.New("extension not allowed: " + ext.ID.String()) + } + + rawValue, err := hex.DecodeString(ext.Value) + if err != nil { + return nil, nil, errors.Annotatef(err, "failed to decode extension") + } + + safeTemplate.ExtraExtensions = append(safeTemplate.ExtraExtensions, pkix.Extension{ + Id: asn1.ObjectIdentifier(ext.ID), + Critical: ext.Critical, + Value: rawValue, + }) + } + } + + err = ca.fillTemplate(&safeTemplate, profile, req.NotBefore, req.NotAfter) + if err != nil { + return nil, nil, errors.Annotatef(err, "failed to populate template") + } + + var certTBS = safeTemplate + + signedCertPEM, err := ca.sign(&certTBS) + if err != nil { + return nil, nil, errors.Trace(err) + } + + crt, err := certutil.ParseFromPEM(signedCertPEM) + if err != nil { + return nil, nil, errors.Trace(err) + } + + // TODO: register issued cert + + return crt, signedCertPEM, nil +} + +func (ca *Issuer) sign(template *x509.Certificate) ([]byte, error) { + var caCert *x509.Certificate + + if ca.bundle == nil { + // self-signed + if !template.IsCA { + return nil, errors.New("CA template is not specified") + } + template.DNSNames = nil + template.EmailAddresses = nil + template.URIs = nil + caCert = template + } else { + caCert = ca.bundle.Cert + } + + derBytes, err := x509.CreateCertificate(rand.Reader, template, caCert, template.PublicKey, ca.signer) + if err != nil { + return nil, errors.Annotatef(err, "create certificate") + } + + logger.Infof("serial=%d, CN=%q, URI=%v, DNS=%v, Email=%v", + template.SerialNumber, template.Subject.CommonName, template.URIs, template.DNSNames, template.EmailAddresses) + + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + return cert, nil +} + +func (ca *Issuer) fillTemplate(template *x509.Certificate, profile *CertProfile, notBefore, notAfter time.Time) error { + ski, err := computeSKI(template) + if err != nil { + return err + } + + /* for debugging + js, _ := json.Marshal(profile) + fmt.Printf("fillTemplate: %v, notBefore=%v, notAfter=%v", string(js), + notBefore.Format(time.RFC3339), + notAfter.Format(time.RFC3339)) + */ + + var ( + eku []x509.ExtKeyUsage + ku x509.KeyUsage + expiry time.Duration = profile.Expiry.TimeDuration() + ) + + if expiry == 0 && notAfter.IsZero() { + return errors.Errorf("expiry is not set") + } + + // The third value returned from Usages is a list of unknown key usages. + // This should be used when validating the profile at load, and isn't used + // here. + ku, eku, _ = profile.Usages() + if ku == 0 && len(eku) == 0 { + return errors.Errorf("invalid profile: no key usages") + } + + if notBefore.IsZero() { + backdate := -1 * profile.Backdate.TimeDuration() + if backdate == 0 { + backdate = -5 * time.Minute + } + notBefore = time.Now().Round(time.Minute).Add(backdate) + } + if notAfter.IsZero() { + notAfter = notBefore.Add(expiry) + } + // TODO: ensure that time from CSR does no exceed allowed in profile + if template.NotBefore.IsZero() || template.NotBefore.Before(notBefore) { + template.NotBefore = notBefore.UTC() + } + if template.NotAfter.IsZero() || notAfter.Before(template.NotAfter) { + template.NotAfter = notAfter.UTC() + } + template.KeyUsage = ku + template.ExtKeyUsage = eku + template.BasicConstraintsValid = true + template.IsCA = profile.CAConstraint.IsCA + if template.IsCA { + logger.Noticef("subject=%q, is_ca=true, MaxPathLen=%d", template.Subject.String(), profile.CAConstraint.MaxPathLen) + template.MaxPathLen = profile.CAConstraint.MaxPathLen + template.MaxPathLenZero = template.MaxPathLen == 0 + template.DNSNames = nil + template.IPAddresses = nil + template.EmailAddresses = nil + template.URIs = nil + } + template.SubjectKeyId = ski + + // TODO: check if profile allows OCSP and CRL + + ocspURL := ca.OcspURL() + if ocspURL != "" { + template.OCSPServer = []string{ocspURL} + } + crlURL := ca.CrlURL() + if crlURL != "" { + template.CRLDistributionPoints = []string{crlURL} + } + issuerURL := ca.AiaURL() + if issuerURL != "" { + template.IssuingCertificateURL = []string{issuerURL} + } + if len(profile.Policies) != 0 { + err = addPolicies(template, profile.Policies) + if err != nil { + return errors.Annotatef(err, "invalid profile policies") + } + } + if profile.OCSPNoCheck { + ocspNoCheckExtension := pkix.Extension{ + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}, + Critical: false, + Value: []byte{0x05, 0x00}, + } + template.ExtraExtensions = append(template.ExtraExtensions, ocspNoCheckExtension) + } + + return nil +} diff --git a/xpki/authority/issuer_test.go b/xpki/authority/issuer_test.go new file mode 100644 index 0000000..b897523 --- /dev/null +++ b/xpki/authority/issuer_test.go @@ -0,0 +1,83 @@ +package authority_test + +import ( + "crypto" + "fmt" + + "github.com/go-phorce/dolly/xpki/authority" +) + +func (s *testSuite) TestNewIssuer() { + + cfg, err := authority.LoadConfig("./testdata/ca-config.dev.yaml") + s.Require().NoError(err) + s.Require().NotNil(cfg.Authority) + s.NotNil(cfg.Authority.DefaultAIA) + + for _, isscfg := range cfg.Authority.Issuers { + if isscfg.GetDisabled() { + continue + } + + issuer, err := authority.NewIssuer(&isscfg, s.crypto) + s.Require().NoError(err) + + s.NotNil(issuer.Bundle()) + s.NotNil(issuer.Signer()) + s.NotEmpty(issuer.PEM()) + s.NotEmpty(issuer.OcspURL()) + s.NotEmpty(issuer.Label()) + s.NotEmpty(issuer.KeyHash(crypto.SHA1)) + s.NotNil(issuer.Profile("client")) + s.Nil(issuer.Profile("notfound")) + + s.Equal(fmt.Sprintf("http://localhost:7880/v1/crl/%s.crl", issuer.SubjectKID()), issuer.CrlURL()) + s.Equal(fmt.Sprintf("http://localhost:7880/v1/certs/%s.crt", issuer.SubjectKID()), issuer.AiaURL()) + //s.NotNil(issuer.AIAExtension("server")) + //s.Nil(issuer.AIAExtension("not_supported")) + } +} + +func (s *testSuite) TestNewIssuerErrors() { + + aia := &authority.AIAConfig{ + AiaURL: "https://localhost/v1/certs/${ISSUER_ID}.crt", + OcspURL: "https://localhost/v1/ocsp", + CrlURL: "https://localhost/v1/crl/${ISSUER_ID}.crl", + } + cfg := &authority.IssuerConfig{ + KeyFile: "not_found", + AIA: aia, + } + _, err := authority.NewIssuer(cfg, s.crypto) + s.Require().Error(err) + s.Equal("unable to create signer: load key file: open not_found: no such file or directory", err.Error()) + + cfg = &authority.IssuerConfig{ + KeyFile: ca2KeyFile, + CertFile: "not_found", + } + _, err = authority.NewIssuer(cfg, s.crypto) + s.Require().Error(err) + s.Equal("failed to load cert: open not_found: no such file or directory", err.Error()) + + cfg = &authority.IssuerConfig{ + CertFile: ca2CertFile, + KeyFile: ca2KeyFile, + CABundleFile: caBundleFile, + RootBundleFile: "not_found", + } + _, err = authority.NewIssuer(cfg, s.crypto) + s.Require().Error(err) + s.Equal("failed to load root-bundle: open not_found: no such file or directory", err.Error()) + + cfg = &authority.IssuerConfig{ + CertFile: ca2CertFile, + KeyFile: ca2KeyFile, + CABundleFile: "not_found", + RootBundleFile: rootBundleFile, + } + _, err = authority.NewIssuer(cfg, s.crypto) + s.Require().Error(err) + s.Equal("failed to load ca-bundle: open not_found: no such file or directory", err.Error()) +} diff --git a/xpki/authority/root.go b/xpki/authority/root.go new file mode 100644 index 0000000..c3e93ef --- /dev/null +++ b/xpki/authority/root.go @@ -0,0 +1,77 @@ +package authority + +import ( + "crypto" + + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/juju/errors" +) + +// NewRoot creates a new root certificate from the certificate request. +func NewRoot(profile string, cfg *Config, provider cryptoprov.Provider, req *csr.CertificateRequest) (certPEM, csrPEM, key []byte, err error) { + err = req.Validate() + if err != nil { + err = errors.Annotate(err, "invalid request") + return + } + + err = cfg.Validate() + if err != nil { + err = errors.Annotate(err, "invalid configuration") + return + } + + var ( + gkey crypto.PrivateKey + keyID string + c = csr.NewProvider(provider) + ) + + csrPEM, gkey, keyID, err = c.GenerateKeyAndRequest(req) + if err != nil { + err = errors.Annotate(err, "process request") + return + } + + signer := gkey.(crypto.Signer) + uri, keyBytes, err := provider.ExportKey(keyID) + if err != nil { + err = errors.Annotate(err, "failed to export key") + return + } + + if keyBytes == nil { + key = []byte(uri) + } else { + key = keyBytes + } + + issuer := &Issuer{ + cfg: IssuerConfig{ + Profiles: cfg.Profiles, + }, + signer: signer, + sigAlgo: csr.DefaultSigAlgo(signer), + } + if cfg.Authority != nil { + issuer.cfg.AIA = cfg.Authority.DefaultAIA + } + + sreq := csr.SignRequest{ + SAN: req.SAN, + Request: string(csrPEM), + Profile: profile, + Subject: &csr.X509Subject{ + CommonName: req.CommonName, + Names: req.Names, + }, + } + + _, certPEM, err = issuer.Sign(sreq) + if err != nil { + err = errors.Annotate(err, "sign request") + return + } + return +} diff --git a/xpki/authority/root_test.go b/xpki/authority/root_test.go new file mode 100644 index 0000000..43fc5e3 --- /dev/null +++ b/xpki/authority/root_test.go @@ -0,0 +1,115 @@ +package authority_test + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "testing" + + "github.com/go-phorce/dolly/algorithms/guid" + "github.com/go-phorce/dolly/xpki/authority" + "github.com/go-phorce/dolly/xpki/certutil" + "github.com/go-phorce/dolly/xpki/cryptoprov/inmemcrypto" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var rootCfg = &authority.Config{ + Profiles: map[string]*authority.CertProfile{ + "ROOT": { + Usage: []string{"cert sign", "crl sign"}, + Expiry: 5 * csr.OneYear, + CAConstraint: authority.CAConstraint{ + IsCA: true, + MaxPathLen: -1, + }, + }, + }, +} + +func (s *testSuite) TestNewRoot() { + crypto := s.crypto.Default() + kr := csr.NewKeyRequest(crypto, "TestNewRoot"+guid.MustCreate(), "ECDSA", 384, csr.SigningKey) + req := csr.CertificateRequest{ + CommonName: "[TEST] Trusty Root CA", + KeyRequest: kr, + } + + certPEM, _, key, err := authority.NewRoot("ROOT", rootCfg, crypto, &req) + s.Require().NoError(err) + + crt, err := certutil.ParseFromPEM(certPEM) + s.Require().NoError(err) + s.Equal(req.CommonName, crt.Subject.CommonName) + s.Equal(req.CommonName, crt.Issuer.CommonName) + s.True(crt.IsCA) + s.True(crt.BasicConstraintsValid) + s.Equal(-1, crt.MaxPathLen) + + _, err = authority.NewSignerFromPEM(s.crypto, key) + s.Require().NoError(err) +} + +func TestNewRootEx(t *testing.T) { + csrCA := ` + { + "common_name": "[TEST] Dolly Root CA", + "names": [ + { + "C": "US", + "L": "CA", + "O": "ekspand.com", + "OU": "dolly-dev" + } + ] + }` + + defprov := inmemcrypto.NewProvider() + prov := csr.NewProvider(defprov) + + req := csr.CertificateRequest{ + KeyRequest: prov.NewKeyRequest("TestNewRootEx", "ECDSA", 256, csr.SigningKey), + } + + _, _, _, err := authority.NewRoot("ROOT", rootCfg, defprov, &req) + require.NoError(t, err) + + err = json.Unmarshal([]byte(csrCA), &req) + require.NoError(t, err, "invalid csr") + + var key, csrPEM, certPEM []byte + certPEM, csrPEM, key, err = authority.NewRoot("ROOT", rootCfg, defprov, &req) + require.NoError(t, err, "init CA") + assert.NotNil(t, csrPEM) + + keyStr := string(key) + assert.Contains(t, keyStr, "BEGIN") + + roots := x509.NewCertPool() + ok := roots.AppendCertsFromPEM([]byte(certPEM)) + require.True(t, ok, "failed to parse root certificate") + + block, _ := pem.Decode([]byte(certPEM)) + require.NotEqual(t, block, "failed to parse certificate PEM") + + cert, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err, "failed to parse certificate") + assert.True(t, cert.IsCA) + assert.Equal(t, "[TEST] Dolly Root CA", cert.Subject.CommonName) + assert.Equal(t, []string{"dolly-dev"}, cert.Subject.OrganizationalUnit) + assert.Equal(t, []string{"ekspand.com"}, cert.Subject.Organization) + assert.Equal(t, cert.KeyUsage, x509.KeyUsageCRLSign|x509.KeyUsageCertSign) + assert.Equal(t, -1, cert.MaxPathLen) + assert.True(t, cert.BasicConstraintsValid) + + opts := x509.VerifyOptions{ + Roots: roots, + KeyUsages: []x509.ExtKeyUsage{ + x509.ExtKeyUsageCodeSigning, + }, + } + + _, err = cert.Verify(opts) + require.NoError(t, err, "failed to verify certificate") +} diff --git a/xpki/authority/signer.go b/xpki/authority/signer.go new file mode 100644 index 0000000..8e2d747 --- /dev/null +++ b/xpki/authority/signer.go @@ -0,0 +1,41 @@ +package authority + +import ( + "crypto" + "io/ioutil" + "strings" + + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/juju/errors" +) + +// TODO: move to cryptoprov.Crypto + +// NewSignerFromFromFile generates a new signer from a caFile +// and a caKey file, both PEM encoded or caKey contains PKCS#11 Uri +func NewSignerFromFromFile(crypto *cryptoprov.Crypto, caKeyFile string) (crypto.Signer, error) { + cakey, err := ioutil.ReadFile(caKeyFile) + if err != nil { + return nil, errors.Annotatef(err, "load key file") + } + // remove trailing space and end-of-line + cakey = []byte(strings.TrimSpace(string(cakey))) + + return NewSignerFromPEM(crypto, cakey) +} + +// NewSignerFromPEM generates a new crypto signer from PEM encoded blocks, +// or caKey contains PKCS#11 Uri +func NewSignerFromPEM(prov *cryptoprov.Crypto, caKey []byte) (crypto.Signer, error) { + _, pvk, err := prov.LoadPrivateKey(caKey) + if err != nil { + return nil, errors.Trace(err) + } + + signer, supported := pvk.(crypto.Signer) + if !supported { + return nil, errors.Errorf("loaded key of %T type does not support crypto.Signer", pvk) + } + + return signer, nil +} diff --git a/xpki/authority/signer_test.go b/xpki/authority/signer_test.go new file mode 100644 index 0000000..f84af9e --- /dev/null +++ b/xpki/authority/signer_test.go @@ -0,0 +1,11 @@ +package authority_test + +import ( + "github.com/go-phorce/dolly/xpki/authority" +) + +func (s *testSuite) TestNewSigner() { + _, err := authority.NewSignerFromFromFile(s.crypto, "not_found") + s.Require().Error(err) + s.Equal("load key file: open not_found: no such file or directory", err.Error()) +} diff --git a/xpki/authority/testdata/ca-config.bootstrap.json b/xpki/authority/testdata/ca-config.bootstrap.json new file mode 100644 index 0000000..5c2e37c --- /dev/null +++ b/xpki/authority/testdata/ca-config.bootstrap.json @@ -0,0 +1,89 @@ +{ + "profiles": { + "default": { + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment" + ] + }, + "peer": { + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "server auth", + "client auth", + "ipsec user", + "ipsec end system" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "server": { + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "server auth", + "ipsec end system" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "client": { + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "client auth", + "ipsec user" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "ROOT": { + "expiry": "43800h", + "backdate": "30m", + "usages": [ + "cert sign", + "crl sign" + ], + "ca_constraint": { + "is_ca": true, + "max_path_len": -1 + } + }, + "L1_CA": { + "expiry": "43800h", + "backdate": "30m", + "usages": [ + "cert sign", + "crl sign" + ], + "ca_constraint": { + "is_ca": true, + "max_path_len": 1 + } + }, + "L2_CA": { + "expiry": "43800h", + "backdate": "30m", + "usages": [ + "cert sign", + "crl sign" + ], + "ca_constraint": { + "is_ca": true, + "max_path_len": 0 + } + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/ca-config.bootstrap.yaml b/xpki/authority/testdata/ca-config.bootstrap.yaml new file mode 100644 index 0000000..47d22f1 --- /dev/null +++ b/xpki/authority/testdata/ca-config.bootstrap.yaml @@ -0,0 +1,74 @@ +--- +profiles: + + default: + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + + peer: + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - server auth + - client auth + - ipsec user + - ipsec end system + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + server: + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - server auth + - ipsec end system + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + client: + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - client auth + - ipsec user + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + ROOT: + expiry: 43800h + backdate: 30m + usages: + - cert sign + - crl sign + ca_constraint: + is_ca: true + max_path_len: -1 + + L1_CA: + expiry: 43800h + backdate: 30m + usages: + - cert sign + - crl sign + ca_constraint: + is_ca: true + max_path_len: 1 + + L2_CA: + expiry: 43800h + backdate: 30m + usages: + - cert sign + - crl sign + ca_constraint: + is_ca: true + max_path_len: 0 diff --git a/xpki/authority/testdata/ca-config.dev.json b/xpki/authority/testdata/ca-config.dev.json new file mode 100644 index 0000000..79607fd --- /dev/null +++ b/xpki/authority/testdata/ca-config.dev.json @@ -0,0 +1,106 @@ +{ + "issuer_url": "http://localhost:7880/v1/certs/${ISSUER_ID}.crt", + "crl_url": "http://localhost:7880/v1/crl/${ISSUER_ID}.crl", + "ocsp_url": "http://localhost:7880/v1/ocsp", + "profiles": { + "default": { + "description": "default profile", + "expiry": "168h", + "backdate": "30m", + "usages": [ + "digital signature", + "key encipherment" + ] + }, + "test_server": { + "description": "test server profile", + "expiry": "5m", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "server auth", + "ipsec end system" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "test_client": { + "description": "test client profile", + "expiry": "5m", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "client auth", + "ipsec user" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "server": { + "description": "server TLS profile", + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "server auth", + "ipsec end system" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "peer": { + "description": "peer TLS profile", + "expiry": "168h", + "backdate": "30m", + "usages": [ + "signing", + "key encipherment", + "key agreement", + "server auth", + "client auth", + "ipsec user", + "ipsec end system" + ], + "allowed_extensions": [ + "1.3.6.1.5.5.7.1.1" + ] + }, + "ocsp": { + "description": "OCSP responder profile", + "expiry": "8760h", + "backdate": "30m", + "usages": [ + "digital signature", + "ocsp signing" + ] + }, + "timestamp": { + "description": "Timestamp certificate profile", + "expiry": "43070h", + "backdate": "30m", + "usages": [ + "digital signature", + "timestamping" + ], + "allowed_extensions": [ + "2.5.29.37" + ] + }, + "codesign": { + "description": "Codesigning certificate profile", + "expiry": "8760h", + "backdate": "30m", + "usages": [ + "digital signature", + "code signing", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/ca-config.dev.yaml b/xpki/authority/testdata/ca-config.dev.yaml new file mode 100644 index 0000000..e6d154b --- /dev/null +++ b/xpki/authority/testdata/ca-config.dev.yaml @@ -0,0 +1,160 @@ +--- +authority: + default_aia: + issuer_url: http://localhost:7880/v1/certs/${ISSUER_ID}.crt + crl_url: http://localhost:7880/v1/crl/${ISSUER_ID}.crl + ocsp_url: http://localhost:7880/v1/ocsp + # value in 72h format for duration of CRL next update time + crl_expiry: 12h + # value in 8h format for duration of CRL renewal before next update time + crl_renewal: 1h + # value in 8h format for duration of OCSP next update time + ocsp_expiry: 30m + + issuers: + - + # specifies Issuer's label + label: dolly.svc + # specifies type: tls|codesign|timestamp|ocsp|spiffe|dolly + type: dolly + cert: /tmp/dolly/certs/test_dolly_issuer2_CA.pem + key: /tmp/dolly/certs/test_dolly_issuer2_CA-key.pem + # location of the CA bundle file + ca_bundle: /tmp/dolly/certs/test_dolly_cabundle.pem + # location of the Root CA file + root_bundle: /tmp/dolly/certs/test_dolly_root_CA.pem + +# profile: +# +# description: string +# expiry: duration +# backdate: duration +# usages: []string +# ocsp_no_check: bool +# allowed_extensions: []string +# allowed_names: regex +# allowed_dns: regex +# allowed_email: regex +# allowed_fields: +# subject: bool +# dns: bool +# ip: bool +# email: bool +# email: uri +# policies: [] +# oid: +# qualifiers: +# ca_constraint: +# is_ca: +# max_path_len: +# +profiles: + + default: + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + + test_server: + description: test server profile + issuer_label: dolly.svc + expiry: 5m + backdate: 30m + usages: # provides a list key usages + - signing + - key encipherment + - server auth + - ipsec end system + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + test_client: + description: test client profile + issuer_label: dolly.svc + expiry: 5m + backdate: 30m + usages: + - signing + - key encipherment + - client auth + - ipsec user + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + peer: + issuer_label: dolly.svc + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - server auth + - client auth + - ipsec user + - ipsec end system + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + allowed_uri: ^spifee://dolly/.*$ + allowed_fields: + uri: true + + server: + issuer_label: dolly.svc + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - server auth + - ipsec end system + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + client: + issuer_label: dolly.svc + expiry: 168h + backdate: 30m + usages: + - signing + - key encipherment + - client auth + - ipsec user + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + allowed_uri: ^spifee://dolly/.*$ + allowed_fields: + uri: true + + ocsp: + description: OCSP responder profile + issuer_label: dolly.svc + expiry: 8760h + backdate: 30m + usages: + - digital signature + - ocsp signing + allowed_extensions: + - 1.3.6.1.5.5.7.1.1 + + timestamp: + description: Timestamp certificate profile + issuer_label: dolly.svc + expiry: 43070h + backdate: 30m + usages: + - digital signature + - timestamping + allowed_extensions: + - 2.5.29.37 + + codesign: + description: Codesigning certificate profile + issuer_label: dolly.svc + expiry: 8760h + backdate: 30m + usages: + - digital signature + - code signing + - key encipherment diff --git a/xpki/authority/testdata/invalid_allowedname.json b/xpki/authority/testdata/invalid_allowedname.json new file mode 100644 index 0000000..8489f51 --- /dev/null +++ b/xpki/authority/testdata/invalid_allowedname.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "withregex": { + "description": "server with unknown usage", + "allowed_names": "[}", + "expiry": "123h", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_default.json b/xpki/authority/testdata/invalid_default.json new file mode 100644 index 0000000..e17a7d4 --- /dev/null +++ b/xpki/authority/testdata/invalid_default.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "default": { + "description": "default profile", + "expiry": "invalid_expiry", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_dns.json b/xpki/authority/testdata/invalid_dns.json new file mode 100644 index 0000000..f760439 --- /dev/null +++ b/xpki/authority/testdata/invalid_dns.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "withregex": { + "description": "server with unknown usage", + "allowed_dns": "[}", + "expiry": "123h", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_email.json b/xpki/authority/testdata/invalid_email.json new file mode 100644 index 0000000..b4ce8ad --- /dev/null +++ b/xpki/authority/testdata/invalid_email.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "withregex": { + "description": "server with unknown usage", + "allowed_dns": "ekspand.com", + "allowed_email": "[}", + "expiry": "123h", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_empty.json b/xpki/authority/testdata/invalid_empty.json new file mode 100644 index 0000000..e0576c6 --- /dev/null +++ b/xpki/authority/testdata/invalid_empty.json @@ -0,0 +1,4 @@ +{ + "profiles": { + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_noexpiry.json b/xpki/authority/testdata/invalid_noexpiry.json new file mode 100644 index 0000000..e2a1413 --- /dev/null +++ b/xpki/authority/testdata/invalid_noexpiry.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "noexpiry_profile": { + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_nousage.json b/xpki/authority/testdata/invalid_nousage.json new file mode 100644 index 0000000..1123d13 --- /dev/null +++ b/xpki/authority/testdata/invalid_nousage.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "no_usage_profile": { + "expiry": "123h" + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_qualifier.json b/xpki/authority/testdata/invalid_qualifier.json new file mode 100644 index 0000000..4996af5 --- /dev/null +++ b/xpki/authority/testdata/invalid_qualifier.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "with-qt": { + "description": "server with unknown usage", + "policies": [ + { + "oid": "1.2.100.1", + "qualifiers": [ + {"type": "qt-type", "value": "not-set"} + ] + } + ], + "expiry": "123h", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_server.json b/xpki/authority/testdata/invalid_server.json new file mode 100644 index 0000000..60c2028 --- /dev/null +++ b/xpki/authority/testdata/invalid_server.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "server": { + "description": "server with unknown usage", + "expiry": "123h", + "usages": [ + "digital signature", + "encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/authority/testdata/invalid_uri.json b/xpki/authority/testdata/invalid_uri.json new file mode 100644 index 0000000..8ed8ee2 --- /dev/null +++ b/xpki/authority/testdata/invalid_uri.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "withregex": { + "description": "server with unknown usage", + "allowed_uri": "[}", + "expiry": "123h", + "usages": [ + "digital signature", + "key encipherment" + ] + } + } +} \ No newline at end of file diff --git a/xpki/csr/csr.go b/xpki/csr/csr.go new file mode 100644 index 0000000..784247c --- /dev/null +++ b/xpki/csr/csr.go @@ -0,0 +1,323 @@ +package csr + +import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "math/big" + "net" + "net/mail" + "net/url" + "strings" + "time" + + "github.com/juju/errors" +) + +// Signer interface to sign CSR +type Signer interface { + SignCertificate(req SignRequest) (cert []byte, err error) +} + +// AllowedFields provides booleans for fields in the CSR. +// If a AllowedFields is not present in a CertProfile, +// all of these fields may be copied from the CSR into the signed certificate. +// If a AllowedFields *is* present in a CertProfile, +// only those fields with a `true` value in the AllowedFields may +// be copied from the CSR to the signed certificate. +// Note that some of these fields, like Subject, can be provided or +// partially provided through the API. +// Since API clients are expected to be trusted, but CSRs are not, fields +// provided through the API are not subject to validation through this +// mechanism. +type AllowedFields struct { + Subject bool `json:"subject" yaml:"subject"` + DNSNames bool `json:"dns" yaml:"dns"` + IPAddresses bool `json:"ip" yaml:"ip"` + EmailAddresses bool `json:"email" yaml:"email"` + URIs bool `json:"uri" yaml:"uri"` +} + +// CertificatePolicy represents the ASN.1 PolicyInformation structure from +// https://tools.ietf.org/html/rfc3280.html#page-106. +// Valid values of Type are "id-qt-unotice" and "id-qt-cps" +type CertificatePolicy struct { + ID OID `json:"oid" yaml:"oid"` + Qualifiers []CertificatePolicyQualifier `json:"qualifiers" yaml:"qualifiers"` +} + +// CertificatePolicyQualifier represents a single qualifier from an ASN.1 +// PolicyInformation structure. +type CertificatePolicyQualifier struct { + Type string `json:"type" yaml:"type"` + Value string `json:"value" yaml:"value"` +} + +// X509Name contains the SubjectInfo fields. +type X509Name struct { + C string `json:"c" yaml:"c"` // Country + ST string `json:"st" yaml:"st"` // State + L string `json:"l" yaml:"l"` // Locality + O string `json:"o" yaml:"o"` // OrganisationName + OU string `json:"ou" yaml:"ou"` // OrganisationalUnitName + SerialNumber string `json:"serial_number" yaml:"serial_number"` +} + +// X509Subject contains the information that should be used to override the +// subject information when signing a certificate. +type X509Subject struct { + CommonName string `json:"common_name" yaml:"common_name"` + Names []X509Name `json:"names" yaml:"names"` + SerialNumber string `json:"serial_number" yaml:"serial_number"` +} + +// X509Extension represents a raw extension to be included in the certificate. The +// "value" field must be hex encoded. +type X509Extension struct { + ID OID `json:"id" yaml:"id"` + Critical bool `json:"critical" yaml:"critical"` + Value string `json:"value" yaml:"value"` +} + +// SignRequest stores a signature request, which contains the SAN, +// the pen-encoded CSR, optional subject information, and the signature profile. +// +// Extensions provided in the request are copied into the certificate, as +// long as they are in the allowed list for the issuer's policy. +// Extensions requested in the CSR are ignored, except for those processed by +// CreateCSR (mainly subjectAltName). +type SignRequest struct { + SAN []string `json:"san" yaml:"san"` + Request string `json:"certificate_request" yaml:"certificate_request"` + Subject *X509Subject `json:"subject,omitempty" yaml:"subject,omitempty"` + Profile string `json:"profile" yaml:"profile"` + SerialNumber *big.Int `json:"serial_number,omitempty" yaml:"serial_number,omitempty"` + Extensions []X509Extension `json:"extensions,omitempty" yaml:"extensions,omitempty"` + + // TODO: label, if supported + //Label string `json:"label"` + + // If provided, NotBefore will be used without modification (except + // for canonicalization) as the value of the notBefore field of the + // certificate. In particular no backdating adjustment will be made + // when NotBefore is provided. + NotBefore time.Time `json:"-" yaml:"-"` + // If provided, NotAfter will be used without modification (except + // for canonicalization) as the value of the notAfter field of the + // certificate. + NotAfter time.Time `json:"-" yaml:"-"` +} + +// A CertificateRequest encapsulates the API interface to the +// certificate request functionality. +type CertificateRequest struct { + // CommonName of the Subject + CommonName string `json:"common_name" yaml:"common_name"` + // Names of the Subject + Names []X509Name `json:"names" yaml:"names"` + // SerialNumber of the Subject + SerialNumber string `json:"serial_number,omitempty" yaml:"serial_number,omitempty"` + // SAN is Subject Alt Names + SAN []string `json:"san" yaml:"san"` + // KeyRequest for generated key + KeyRequest KeyRequest `json:"key,omitempty" yaml:"key,omitempty"` +} + +// Validate provides the default validation logic for certificate +// authority certificates. The only requirement here is that the +// certificate have a non-empty subject field. +func (r *CertificateRequest) Validate() error { + if r.CommonName != "" { + return nil + } + + // if len(r.Names) == 0 { + // return errors.New("missing subject information") + // } + + for _, n := range r.Names { + if isNameEmpty(n) { + return errors.New("empty name") + } + } + + return nil +} + +// Name returns the PKIX name for the request. +func (r *CertificateRequest) Name() pkix.Name { + name := pkix.Name{ + CommonName: r.CommonName, + SerialNumber: r.SerialNumber, + } + + for _, n := range r.Names { + appendIf(n.C, &name.Country) + appendIf(n.ST, &name.Province) + appendIf(n.L, &name.Locality) + appendIf(n.O, &name.Organization) + appendIf(n.OU, &name.OrganizationalUnit) + } + + return name +} + +// isNameEmpty returns true if the name has no identifying information in it. +func isNameEmpty(n X509Name) bool { + empty := func(s string) bool { return strings.TrimSpace(s) == "" } + + if empty(n.C) && empty(n.ST) && empty(n.L) && empty(n.O) && empty(n.OU) { + return true + } + return false +} + +// appendIf appends to a if s is not an empty string. +func appendIf(s string, a *[]string) { + if s != "" { + *a = append(*a, s) + } +} + +// Parse takes an incoming certificate request and +// builds a certificate template from it. +func Parse(csrBytes []byte) (*x509.Certificate, error) { + csrv, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + return nil, errors.Annotatef(err, "failed to parse") + } + + err = csrv.CheckSignature() + if err != nil { + return nil, errors.Annotatef(err, "key mismatch") + } + + template := &x509.Certificate{ + Subject: csrv.Subject, + PublicKeyAlgorithm: csrv.PublicKeyAlgorithm, + PublicKey: csrv.PublicKey, + DNSNames: csrv.DNSNames, + IPAddresses: csrv.IPAddresses, + EmailAddresses: csrv.EmailAddresses, + URIs: csrv.URIs, + } + + for _, val := range csrv.Extensions { + // Check the CSR for the X.509 BasicConstraints (RFC 5280, 4.2.1.9) + // extension and append to template if necessary + if val.Id.Equal(BasicConstraintsOID) { + var constraints BasicConstraints + var rest []byte + + if rest, err = asn1.Unmarshal(val.Value, &constraints); err != nil { + return nil, errors.Annotate(err, "failed to parse BasicConstraints") + } else if len(rest) != 0 { + return nil, errors.New("failed to parse BasicConstraints: trailing data") + } + + template.BasicConstraintsValid = true + template.IsCA = constraints.IsCA + template.MaxPathLen = constraints.MaxPathLen + template.MaxPathLenZero = template.MaxPathLen == 0 + } + } + + return template, nil +} + +// ParsePEM takes an incoming certificate request and +// builds a certificate template from it. +func ParsePEM(csrPEM []byte) (*x509.Certificate, error) { + block, _ := pem.Decode([]byte(csrPEM)) + if block == nil { + return nil, errors.New("unable to parse PEM") + } + + if block.Type != "NEW CERTIFICATE REQUEST" && block.Type != "CERTIFICATE REQUEST" { + return nil, errors.Errorf("unsupported type in PEM: " + block.Type) + } + + return Parse(block.Bytes) +} + +type subjectPublicKeyInfo struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString +} + +// Name returns the PKIX name for the subject. +func (s *X509Subject) Name() pkix.Name { + var name pkix.Name + name.CommonName = s.CommonName + name.SerialNumber = s.SerialNumber + for _, n := range s.Names { + appendIf(n.C, &name.Country) + appendIf(n.ST, &name.Province) + appendIf(n.L, &name.Locality) + appendIf(n.O, &name.Organization) + appendIf(n.OU, &name.OrganizationalUnit) + } + return name +} + +// PopulateName has functionality similar to Name, except +// it fills the fields of the resulting pkix.Name with req's if the +// subject's corresponding fields are empty +func PopulateName(s *X509Subject, req pkix.Name) pkix.Name { + // if no subject, use req + if s == nil { + return req + } + + name := s.Name() + + if name.CommonName == "" { + name.CommonName = req.CommonName + } + + replaceSliceIfEmpty(&name.Country, &req.Country) + replaceSliceIfEmpty(&name.Province, &req.Province) + replaceSliceIfEmpty(&name.Locality, &req.Locality) + replaceSliceIfEmpty(&name.Organization, &req.Organization) + replaceSliceIfEmpty(&name.OrganizationalUnit, &req.OrganizationalUnit) + if name.SerialNumber == "" { + name.SerialNumber = req.SerialNumber + } + return name +} + +// replaceSliceIfEmpty replaces the contents of replaced with newContents if +// the slice referenced by replaced is empty +func replaceSliceIfEmpty(replaced, newContents *[]string) { + if len(*replaced) == 0 { + *replaced = *newContents + } +} + +// SetSAN fills template's IPAddresses, EmailAddresses, and DNSNames with the +// content of SAN, if it is not nil. +func SetSAN(template *x509.Certificate, SAN []string) { + if SAN != nil { + template.IPAddresses = []net.IP{} + template.EmailAddresses = []string{} + template.DNSNames = []string{} + template.URIs = []*url.URL{} + } + + for _, san := range SAN { + if strings.Contains(san, "://") { + u, err := url.Parse(san) + if err != nil { + logger.Errorf("uri=%q, err=%q", san, err.Error()) + } + template.URIs = append(template.URIs, u) + } else if ip := net.ParseIP(san); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else if email, err := mail.ParseAddress(san); err == nil && email != nil { + template.EmailAddresses = append(template.EmailAddresses, email.Address) + } else { + template.DNSNames = append(template.DNSNames, san) + } + } +} diff --git a/xpki/csr/csr_test.go b/xpki/csr/csr_test.go new file mode 100644 index 0000000..e0b94da --- /dev/null +++ b/xpki/csr/csr_test.go @@ -0,0 +1,157 @@ +package csr_test + +import ( + "crypto/x509" + "testing" + + "github.com/go-phorce/dolly/xpki/certutil" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCertificateRequestValidate(t *testing.T) { + tcases := []struct { + r *csr.CertificateRequest + err string + }{ + { + r: &csr.CertificateRequest{CommonName: "ekspand.com"}, + err: "", + }, + { + r: &csr.CertificateRequest{ + Names: []csr.X509Name{{O: "ekspand"}}, + }, + err: "", + }, + { + r: &csr.CertificateRequest{Names: []csr.X509Name{{}}}, + err: "empty name", + }, + } + + for _, tc := range tcases { + err := tc.r.Validate() + if tc.err != "" { + require.Error(t, err) + assert.Equal(t, tc.err, err.Error()) + } else { + assert.NoError(t, err) + } + } +} + +func TestCertificateRequestName(t *testing.T) { + r := &csr.CertificateRequest{ + CommonName: "ekspand.com", + SerialNumber: "1234", + Names: []csr.X509Name{ + { + O: "ekspand", + ST: "WA", + C: "US", + }, + }, + } + + n := r.Name() + assert.Equal(t, "SERIALNUMBER=1234,CN=ekspand.com,O=ekspand,ST=WA,C=US", n.String()) +} + +func TestX509SubjectName(t *testing.T) { + r := &csr.X509Subject{ + CommonName: "ekspand.com", + SerialNumber: "1234", + Names: []csr.X509Name{ + { + O: "ekspand", + ST: "WA", + C: "US", + }, + }, + } + + n := r.Name() + assert.Equal(t, "SERIALNUMBER=1234,CN=ekspand.com,O=ekspand,ST=WA,C=US", n.String()) +} + +func TestPopulateName(t *testing.T) { + req := &csr.CertificateRequest{ + CommonName: "ekspand.com", + SerialNumber: "1234", + Names: []csr.X509Name{ + { + O: "ekspand", + ST: "CA", + C: "USA", + }, + }, + } + n := req.Name() + + subj := &csr.X509Subject{ + Names: []csr.X509Name{ + { + O: "ekspand.com", + ST: "WA", + C: "US", + }, + }, + } + n2 := csr.PopulateName(nil, n) + assert.Equal(t, "SERIALNUMBER=1234,CN=ekspand.com,O=ekspand,ST=CA,C=USA", n2.String()) + + n2 = csr.PopulateName(subj, n) + assert.Equal(t, "SERIALNUMBER=1234,CN=ekspand.com,O=ekspand.com,ST=WA,C=US", n2.String()) +} + +func TestParsePEM(t *testing.T) { + pem := `-----BEGIN CERTIFICATE REQUEST----- +MIIBSjCB0QIBADBSMQswCQYDVQQGEwJVUzELMAkGA1UEBxMCV0ExEzARBgNVBAoT +CnRydXN0eS5jb20xITAfBgNVBAMMGFtURVNUXSBUcnVzdHkgTGV2ZWwgMSBDQTB2 +MBAGByqGSM49AgEGBSuBBAAiA2IABITXg6XB0tSqS+8gLJ8iPEErcIkiXzA2VFuo +Y/joGvOXaq2GXQyOLXPXDLf0LlTNcQww6McTQUBRjocT7USwhR0EdTS4tfdgQi53 +lE9lpMy4V5Gbg9x0t08PQ4EpXM+2KaAAMAoGCCqGSM49BAMDA2gAMGUCMQCut6W1 +r6sX2RQbFtUPYEjg2EJdwo8KP0KMzDQEzdh0TzkFaTSxBvMjSR9L2HuntIYCMCuZ +18vhP1NmhNWaLmAPbbukNMhlrDgsezJXzN+/RFv3LCzzOLzHR4V90x6sb2jhmQ== +-----END CERTIFICATE REQUEST----- +` + crt, err := csr.ParsePEM([]byte(pem)) + require.NoError(t, err) + assert.Equal(t, "C=US, L=WA, O=trusty.com, CN=[TEST] Trusty Level 1 CA", certutil.NameToString(&crt.Subject)) + + pem = `-----BEGIN CERTIFICATE REQUEST----- + MIICiDCCAXACAQAwQzELMAkGA1UEBhMCVVMxCzAJBgNVBAcTAldBMRMwEQYDVQQK + Ewp0cnVzdHkuY29tMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB + /ZgtJhZdT3bQjaXopUfn4faiL1aCYWlLr8BEJQ== + -----END CERTIFICATE REQUEST----- + ` + _, err = csr.ParsePEM([]byte(pem)) + require.Error(t, err) + assert.Equal(t, "unable to parse PEM", err.Error()) + + pem = `-----BEGIN CERTIFICATE----- +MIICiDCCAXACAQAwQzELMAkGA1UEBhMCVVMxCzAJBgNVBAcTAldBMRMwEQYDVQQK +Ewp0cnVzdHkuY29tMRIwEAYDVQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +-----END CERTIFICATE----- + ` + _, err = csr.ParsePEM([]byte(pem)) + require.Error(t, err) + assert.Equal(t, "unsupported type in PEM: CERTIFICATE", err.Error()) +} + +func TestSetSAN(t *testing.T) { + template := x509.Certificate{} + + csr.SetSAN(&template, []string{ + "ekspand.com", + "localhost", + "127.0.0.1", + "::0", + "ca@trusty.com", + }) + assert.Len(t, template.DNSNames, 2) + assert.Len(t, template.EmailAddresses, 1) + assert.Len(t, template.IPAddresses, 2) +} diff --git a/xpki/csr/csrprov.go b/xpki/csr/csrprov.go new file mode 100644 index 0000000..af85bbe --- /dev/null +++ b/xpki/csr/csrprov.go @@ -0,0 +1,184 @@ +package csr + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "net" + "net/mail" + "net/url" + "strings" + + "github.com/go-phorce/dolly/xlog" + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/juju/errors" +) + +var logger = xlog.NewPackageLogger("github.com/go-phorce/dolly/xpki", "csr") + +// Provider extends cryptoprov.Crypto functionality to support CSP procesing +// and certificate signing +type Provider struct { + provider cryptoprov.Provider +} + +// NewProvider returns an instance of CSR provider +func NewProvider(provider cryptoprov.Provider) *Provider { + return &Provider{ + provider: provider, + } +} + +// NewSigningCertificateRequest creates new request for signing certificate +func (c *Provider) NewSigningCertificateRequest( + keyLabel, algo string, keySize int, + CN string, + names []X509Name, + san []string, +) *CertificateRequest { + return &CertificateRequest{ + KeyRequest: c.NewKeyRequest(keyLabel, algo, keySize, SigningKey), + CommonName: CN, + Names: names, + SAN: san, + } +} + +// CreateRequestAndExportKey takes a certificate request and generates a key and +// CSR from it. +func (c *Provider) CreateRequestAndExportKey(req *CertificateRequest) (csrPEM, key []byte, keyID string, pub crypto.PublicKey, err error) { + err = req.Validate() + if err != nil { + err = errors.Annotate(err, "invalid request") + return + } + + var priv crypto.PrivateKey + + csrPEM, priv, keyID, err = c.GenerateKeyAndRequest(req) + if err != nil { + key = nil + err = errors.Annotate(err, "process request") + return + } + + s, ok := priv.(crypto.Signer) + if !ok { + key = nil + err = errors.Annotate(err, "unable to convert key to crypto.Signer") + return + } + pub = s.Public() + + uri, keyBytes, err := c.provider.ExportKey(keyID) + if err != nil { + err = errors.Annotate(err, "key URI") + return + } + + if keyBytes == nil { + key = []byte(uri) + } else { + key = keyBytes + } + + return +} + +// GenerateKeyAndRequest takes a certificate request and generates a key and +// CSR from it. +func (c *Provider) GenerateKeyAndRequest(req *CertificateRequest) (csrPEM []byte, priv crypto.PrivateKey, keyID string, err error) { + if req.KeyRequest == nil { + err = errors.New("invalid key request") + return + } + + logger.Tracef("algo=%s, size=%d", + req.KeyRequest.Algo(), req.KeyRequest.Size()) + + priv, err = req.KeyRequest.Generate() + if err != nil { + err = errors.Annotate(err, "generate key") + return + } + + var label string + keyID, label, err = c.provider.IdentifyKey(priv) + if err != nil { + err = errors.Annotate(err, "identify key") + return + } + + logger.Tracef("key_id=%q, label=%q", keyID, label) + var template = x509.CertificateRequest{ + Subject: req.Name(), + SignatureAlgorithm: req.KeyRequest.SigAlgo(), + } + + for _, san := range req.SAN { + if strings.Contains(san, "://") { + u, err := url.Parse(san) + if err != nil { + logger.Errorf("uri=%q, err=%q", san, err.Error()) + } + template.URIs = append(template.URIs, u) + } else if ip := net.ParseIP(san); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else if email, err := mail.ParseAddress(san); err == nil && email != nil { + template.EmailAddresses = append(template.EmailAddresses, email.Address) + } else { + template.DNSNames = append(template.DNSNames, san) + } + } + + csrPEM, err = x509.CreateCertificateRequest(rand.Reader, &template, priv) + if err != nil { + err = errors.Annotate(err, "create CSR") + return + } + block := pem.Block{ + Type: "CERTIFICATE REQUEST", + Bytes: csrPEM, + } + + csrPEM = pem.EncodeToMemory(&block) + + return +} + +// DefaultSigAlgo returns an appropriate X.509 signature algorithm given +// the CA's private key. +func DefaultSigAlgo(priv crypto.Signer) x509.SignatureAlgorithm { + pub := priv.Public() + switch pub := pub.(type) { + case *rsa.PublicKey: + keySize := pub.N.BitLen() + switch { + case keySize >= 4096: + return x509.SHA512WithRSA + case keySize >= 3072: + return x509.SHA384WithRSA + case keySize >= 2048: + return x509.SHA256WithRSA + default: + return x509.SHA1WithRSA + } + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P256(): + return x509.ECDSAWithSHA256 + case elliptic.P384(): + return x509.ECDSAWithSHA384 + case elliptic.P521(): + return x509.ECDSAWithSHA512 + default: + return x509.ECDSAWithSHA1 + } + default: + return x509.UnknownSignatureAlgorithm + } +} diff --git a/xpki/csr/csrprov_test.go b/xpki/csr/csrprov_test.go new file mode 100644 index 0000000..2dd0a5e --- /dev/null +++ b/xpki/csr/csrprov_test.go @@ -0,0 +1,141 @@ +package csr_test + +import ( + "crypto" + "testing" + + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func loadProvider(t *testing.T) cryptoprov.Provider { + cfgfile := "/tmp/dolly/softhsm_unittest.json" + + err := cryptoprov.Register("SoftHSM", cryptoprov.Crypto11Loader) + assert.NoError(t, err) + defer cryptoprov.Unregister("SoftHSM") + + p, err := cryptoprov.LoadProvider(cfgfile) + require.NoError(t, err) + + assert.Equal(t, "SoftHSM", p.Manufacturer()) + + return p +} + +func TestGenerateKeyAndRequest(t *testing.T) { + defprov := loadProvider(t) + prov := csr.NewProvider(defprov) + + tt := []struct { + name string + req *csr.CertificateRequest + experr string + }{ + { + name: "no key", + req: &csr.CertificateRequest{}, + experr: "invalid key request", + }, + { + name: "valid rsa", + req: prov.NewSigningCertificateRequest("label", "RSA", 2048, "localhost", []csr.X509Name{ + { + O: "org1", + OU: "unit1", + }, + }, []string{"127.0.0.1", "localhost"}), + experr: "", + }, + { + name: "valid rsa", + req: prov.NewSigningCertificateRequest("label", "ECDSA", 256, "localhost", []csr.X509Name{ + { + O: "org1", + OU: "unit1", + }, + }, []string{"127.0.0.1", "localhost"}), + experr: "", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + cr, k, kid, err := prov.GenerateKeyAndRequest(tc.req) + if tc.experr != "" { + assert.Nil(t, k) + require.Error(t, err) + assert.Equal(t, tc.experr, err.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, cr) + require.NotNil(t, k) + assert.NotEmpty(t, kid) + + signer := k.(crypto.Signer) + assert.Equal(t, tc.req.KeyRequest.SigAlgo(), csr.DefaultSigAlgo(signer)) + } + }) + } +} + +func TestCreateRequestAndExportKey(t *testing.T) { + defprov := loadProvider(t) + prov := csr.NewProvider(defprov) + + tt := []struct { + name string + req *csr.CertificateRequest + experr string + }{ + { + name: "empty", + req: &csr.CertificateRequest{}, + experr: "process request: invalid key request", + }, + { + name: "no key", + req: &csr.CertificateRequest{CommonName: "localhost"}, + experr: "process request: invalid key request", + }, + { + name: "valid rsa", + req: prov.NewSigningCertificateRequest("label", "RSA", 2048, "localhost", []csr.X509Name{ + { + O: "org1", + OU: "unit1", + }, + }, []string{"127.0.0.1", "localhost"}), + experr: "", + }, + { + name: "valid rsa", + req: prov.NewSigningCertificateRequest("label", "ECDSA", 256, "localhost", []csr.X509Name{ + { + O: "org1", + OU: "unit1", + }, + }, []string{"127.0.0.1", "localhost"}), + experr: "", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + cr, k, kid, pub, err := prov.CreateRequestAndExportKey(tc.req) + if tc.experr != "" { + assert.Nil(t, k) + require.Error(t, err) + assert.Equal(t, tc.experr, err.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, cr) + require.NotNil(t, k) + require.NotNil(t, pub) + assert.NotEmpty(t, kid) + } + }) + } +} diff --git a/xpki/csr/keyreq.go b/xpki/csr/keyreq.go new file mode 100644 index 0000000..ec87b75 --- /dev/null +++ b/xpki/csr/keyreq.go @@ -0,0 +1,194 @@ +package csr + +import ( + "crypto" + "crypto/elliptic" + "crypto/x509" + "strings" + + "github.com/go-phorce/dolly/xpki/cryptoprov" + "github.com/juju/errors" +) + +const ( + // CurveP256 specifies curve P-256 for ESDCA + CurveP256 = 256 + + // CurveP384 specifies curve P-384 for ESDCA + CurveP384 = 384 + + // CurveP521 specifies curve P-521 for ESDCA + CurveP521 = 521 +) + +// KeyPurpose declares the purpose for keys +type KeyPurpose int + +const ( + // Undefined purpose of key + Undefined KeyPurpose = 0 + // SigningKey specifies the purpose of key to be used in signing/verification operations + SigningKey KeyPurpose = 1 + // EncryptionKey specifies the purpose of key to be used in encryption/decryption operations + EncryptionKey KeyPurpose = 2 +) + +// KeyRequest contains the algorithm and key size for a new private key. +type KeyRequest interface { + Algo() string + Label() string + Size() int + Generate() (crypto.PrivateKey, error) + SigAlgo() x509.SignatureAlgorithm + Purpose() int +} + +// keyRequest contains the algorithm and key size for a new private key. +type keyRequest struct { + L string `json:"label"` + A string `json:"algo"` + S int `json:"size"` + P KeyPurpose `json:"purpose"` + prov cryptoprov.Provider +} + +// Label returns the requested key label. +func (kr *keyRequest) Label() string { + return kr.L +} + +// Algo returns the requested key algorithm represented as a string. +func (kr *keyRequest) Algo() string { + return kr.A +} + +// Size returns the requested key size. +func (kr *keyRequest) Size() int { + return kr.S +} + +// Purpose returns the purpose of the key . +func (kr *keyRequest) Purpose() int { + return int(kr.P) +} + +// SigAlgo returns an appropriate X.509 signature algorithm given the +// key request's type and size. +func (kr *keyRequest) SigAlgo() x509.SignatureAlgorithm { + return SigAlgo(kr.Algo(), kr.Size()) +} + +// Generate generates a key as specified in the request. Currently, +// only ECDSA and RSA are supported. +func (kr *keyRequest) Generate() (crypto.PrivateKey, error) { + switch algo := kr.Algo(); strings.ToUpper(algo) { + case "RSA": + err := validateRSAKeyPairInfoHandler(kr.Size()) + if err != nil { + return nil, errors.Annotate(err, "validate RSA key") + } + pk, err := kr.prov.GenerateRSAKey(kr.Label(), kr.Size(), kr.Purpose()) + if err != nil { + return nil, errors.Annotate(err, "generate RSA key") + } + return pk, nil + case "ECDSA": + err := validateECDSAKeyPairCurveInfoHandler(kr.Size()) + if err != nil { + return nil, errors.Annotate(err, "validate ECDSA key") + } + + var curve elliptic.Curve + switch kr.Size() { + case CurveP256: + curve = elliptic.P256() + case CurveP384: + curve = elliptic.P384() + case CurveP521: + curve = elliptic.P521() + default: + return nil, errors.New("invalid curve") + } + pk, err := kr.prov.GenerateECDSAKey(kr.Label(), curve) + if err != nil { + return nil, errors.Annotate(err, "generate ECDSA key") + } + return pk, nil + default: + return nil, errors.Errorf("invalid algorithm: %s", algo) + } +} + +// NewKeyRequest returns KeyRequest from given parameters +func (c *Provider) NewKeyRequest(label, algo string, keySize int, purpose KeyPurpose) KeyRequest { + return &keyRequest{ + L: label, + A: algo, + S: keySize, + P: purpose, + prov: c.provider, + } +} + +// NewKeyRequest returns KeyRequest from given parameters +func NewKeyRequest(prov cryptoprov.Provider, label, algo string, keySize int, purpose KeyPurpose) KeyRequest { + return &keyRequest{ + L: label, + A: algo, + S: keySize, + P: purpose, + prov: prov, + } +} + +// validateRSAKeyPairInfoHandler validates size of the RSA key +func validateRSAKeyPairInfoHandler(size int) error { + if size < 2048 { + return errors.Errorf("RSA key is too weak: %d", size) + } + if size > 4096 { + return errors.Errorf("RSA key size too large: %d", size) + } + + return nil +} + +// validateECDSAKeyPairCurveInfoHandler validates size of the ECDSA key +func validateECDSAKeyPairCurveInfoHandler(size int) error { + switch size { + case CurveP256, CurveP384, CurveP521: + return nil + } + return errors.Errorf("invalid curve size: %d", size) +} + +// SigAlgo returns signature algorithm for the given algorithm name and key size +// TODO: use oid pkg +func SigAlgo(algo string, size int) x509.SignatureAlgorithm { + switch strings.ToUpper(algo) { + case "RSA": + switch { + case size >= 4096: + return x509.SHA512WithRSA + case size >= 3072: + return x509.SHA384WithRSA + case size >= 2048: + return x509.SHA256WithRSA + default: + return x509.SHA1WithRSA + } + case "ECDSA": + switch size { + case CurveP521: + return x509.ECDSAWithSHA512 + case CurveP384: + return x509.ECDSAWithSHA384 + case CurveP256: + return x509.ECDSAWithSHA256 + default: + return x509.ECDSAWithSHA1 + } + default: + return x509.UnknownSignatureAlgorithm + } +} diff --git a/xpki/csr/keyreq_test.go b/xpki/csr/keyreq_test.go new file mode 100644 index 0000000..d3617c8 --- /dev/null +++ b/xpki/csr/keyreq_test.go @@ -0,0 +1,54 @@ +package csr_test + +import ( + "crypto" + "crypto/x509" + "fmt" + "testing" + + "github.com/go-phorce/dolly/xpki/cryptoprov/inmemcrypto" + "github.com/go-phorce/dolly/xpki/csr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKeyRequest(t *testing.T) { + defprov := inmemcrypto.NewProvider() + tt := []struct { + algo string + size int + expalg x509.SignatureAlgorithm + experr string + }{ + {"rsa", 512, x509.SHA1WithRSA, "validate RSA key: RSA key is too weak: 512"}, + {"RSA", 1024, x509.SHA1WithRSA, "validate RSA key: RSA key is too weak: 1024"}, + {"RSA", 2048, x509.SHA256WithRSA, ""}, + {"RSA", 3072, x509.SHA384WithRSA, ""}, + {"rsa", 4096, x509.SHA512WithRSA, ""}, + {"rsa", 8192, x509.SHA512WithRSA, "validate RSA key: RSA key size too large: 8192"}, + {"rsa", 168192, x509.SHA512WithRSA, "validate RSA key: RSA key size too large: 168192"}, + {"ecdsa", 521, x509.ECDSAWithSHA512, ""}, + {"ECDSA", 384, x509.ECDSAWithSHA384, ""}, + {"ECDSA", 256, x509.ECDSAWithSHA256, ""}, + {"ECDSA", 128, x509.ECDSAWithSHA1, "validate ECDSA key: invalid curve size: 128"}, + {"DSA", 256, x509.UnknownSignatureAlgorithm, "invalid algorithm: DSA"}, + } + + for _, tc := range tt { + label := fmt.Sprintf("%s_%d", tc.algo, tc.size) + t.Run(label, func(t *testing.T) { + assert.Equal(t, tc.expalg, csr.SigAlgo(tc.algo, tc.size)) + + kr := csr.NewKeyRequest(defprov, label, tc.algo, tc.size, csr.SigningKey) + priv, err := kr.Generate() + if tc.experr != "" { + require.Error(t, err) + assert.Equal(t, tc.experr, err.Error()) + } else { + require.NoError(t, err) + signer := priv.(crypto.Signer) + assert.Equal(t, tc.expalg, csr.DefaultSigAlgo(signer)) + } + }) + } +} diff --git a/xpki/csr/types.go b/xpki/csr/types.go new file mode 100644 index 0000000..2101401 --- /dev/null +++ b/xpki/csr/types.go @@ -0,0 +1,183 @@ +package csr + +import ( + "crypto/x509" + "encoding/asn1" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/juju/errors" +) + +const ( + // UserNoticeQualifierType defines id-qt-unotice + UserNoticeQualifierType = "id-qt-unotice" + // CpsQualifierType defines id-qt-cps + CpsQualifierType = "id-qt-cps" + + // OneYear duration + OneYear = Duration(8760 * time.Hour) +) + +// BasicConstraintsOID specifies OID for BasicConstraints +var BasicConstraintsOID = asn1.ObjectIdentifier{2, 5, 29, 19} + +// BasicConstraints CSR information RFC 5280, 4.2.1.9 +type BasicConstraints struct { + IsCA bool `asn1:"optional"` + MaxPathLen int `asn1:"optional,default:-1"` +} + +// KeyUsage contains a mapping of string names to key usages. +var KeyUsage = map[string]x509.KeyUsage{ + "signing": x509.KeyUsageDigitalSignature, + "digital signature": x509.KeyUsageDigitalSignature, + "content commitment": x509.KeyUsageContentCommitment, + "key encipherment": x509.KeyUsageKeyEncipherment, + "key agreement": x509.KeyUsageKeyAgreement, + "data encipherment": x509.KeyUsageDataEncipherment, + "cert sign": x509.KeyUsageCertSign, + "crl sign": x509.KeyUsageCRLSign, + "encipher only": x509.KeyUsageEncipherOnly, + "decipher only": x509.KeyUsageDecipherOnly, +} + +// ExtKeyUsage contains a mapping of string names to extended key +// usages. +var ExtKeyUsage = map[string]x509.ExtKeyUsage{ + "any": x509.ExtKeyUsageAny, + "server auth": x509.ExtKeyUsageServerAuth, + "client auth": x509.ExtKeyUsageClientAuth, + "code signing": x509.ExtKeyUsageCodeSigning, + "email protection": x509.ExtKeyUsageEmailProtection, + "s/mime": x509.ExtKeyUsageEmailProtection, + "ipsec end system": x509.ExtKeyUsageIPSECEndSystem, + "ipsec tunnel": x509.ExtKeyUsageIPSECTunnel, + "ipsec user": x509.ExtKeyUsageIPSECUser, + "timestamping": x509.ExtKeyUsageTimeStamping, + "ocsp signing": x509.ExtKeyUsageOCSPSigning, + "microsoft sgc": x509.ExtKeyUsageMicrosoftServerGatedCrypto, + "netscape sgc": x509.ExtKeyUsageNetscapeServerGatedCrypto, +} + +// OID is the asn1's ObjectIdentifier, provide a custom +// JSON marshal / unmarshal. +type OID asn1.ObjectIdentifier + +// Equal reports whether oi and other represent the same identifier. +func (oid OID) Equal(other OID) bool { + return asn1.ObjectIdentifier(oid).Equal(asn1.ObjectIdentifier(other)) +} + +func (oid OID) String() string { + return asn1.ObjectIdentifier(oid).String() +} + +// UnmarshalJSON unmarshals a JSON string into an OID. +func (oid *OID) UnmarshalJSON(data []byte) (err error) { + last := len(data) - 1 + if data[0] != '"' || data[last] != '"' { + return errors.New("OID JSON string not wrapped in quotes: " + string(data)) + } + parsedOid, err := parseObjectIdentifier(string(data[1:last])) + if err != nil { + return err + } + *oid = OID(parsedOid) + return +} + +// UnmarshalYAML unmarshals a YAML string into an OID. +func (oid *OID) UnmarshalYAML(unmarshal func(interface{}) error) error { + var buf string + err := unmarshal(&buf) + if err != nil { + return err + } + + parsedOid, err := parseObjectIdentifier(buf) + if err != nil { + return err + } + *oid = OID(parsedOid) + return err +} + +// MarshalJSON marshals an oid into a JSON string. +func (oid OID) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%v"`, asn1.ObjectIdentifier(oid))), nil +} + +func parseObjectIdentifier(oidString string) (oid asn1.ObjectIdentifier, err error) { + validOID, err := regexp.MatchString("\\d(\\.\\d+)*", oidString) + if err != nil { + return + } + if !validOID { + err = errors.Errorf("invalid OID: %q", oidString) + return + } + + segments := strings.Split(oidString, ".") + oid = make(asn1.ObjectIdentifier, len(segments)) + for i, intString := range segments { + oid[i], err = strconv.Atoi(intString) + if err != nil { + err = errors.Annotatef(err, "invalid OID") + return + } + } + return +} + +// Duration represents a period of time, its the same as time.Duration +// but supports better marshalling from json +type Duration time.Duration + +// UnmarshalJSON handles decoding our custom json serialization for Durations +// json values that are numbers are treated as seconds +// json values that are strings, can use the standard time.Duration units indicators +// e.g. this can decode val:100 as well as val:"10m" +func (d *Duration) UnmarshalJSON(b []byte) error { + if b[0] == '"' { + dir, err := time.ParseDuration(string(b[1 : len(b)-1])) + *d = Duration(dir) + return err + } + i, err := json.Number(string(b)).Int64() + *d = Duration(time.Duration(i) * time.Second) + return err +} + +// UnmarshalYAML handles decoding our custom json serialization for Durations +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { + var buf string + err := unmarshal(&buf) + if err != nil { + return err + } + + dir, err := time.ParseDuration(buf) + *d = Duration(dir) + return err +} + +// MarshalJSON encodes our custom Duration value as a quoted version of its underlying value's String() output +// this means you get a duration with a trailing units indicator, e.g. "10m0s" +func (d Duration) MarshalJSON() ([]byte, error) { + return []byte(`"` + d.String() + `"`), nil +} + +// String returns a string formatted version of the duration in a valueUnits format, e.g. 5m0s for 5 minutes +func (d Duration) String() string { + return time.Duration(d).String() +} + +// TimeDuration returns this duration in a time.Duration type +func (d Duration) TimeDuration() time.Duration { + return time.Duration(d) +} diff --git a/xpki/csr/types_test.go b/xpki/csr/types_test.go new file mode 100644 index 0000000..3f9c2fd --- /dev/null +++ b/xpki/csr/types_test.go @@ -0,0 +1,88 @@ +package csr + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDurationString(t *testing.T) { + f := func(d time.Duration, exp string) { + actual := Duration(d).String() + require.Equal(t, exp, actual) + } + f(time.Second, "1s") + f(time.Second*30, "30s") + f(time.Minute, "1m0s") + f(time.Second*90, "1m30s") + f(0, "0s") +} + +func TestDurationJSON(t *testing.T) { + f := func(d time.Duration, exp string) { + v := Duration(d) + bytes, err := json.Marshal(&v) + require.NoError(t, err) + require.Equal(t, exp, string(bytes)) + var decoded Duration + + err = json.Unmarshal(bytes, &decoded) + require.NoError(t, err) + + assert.Equal(t, v, decoded) + } + f(0, `"0s"`) + f(time.Second, `"1s"`) + f(time.Minute*5, `"5m0s"`) + f(time.Second*90, `"1m30s"`) + f(time.Hour*2, `"2h0m0s"`) + f(time.Millisecond*10, `"10ms"`) +} + +func TestDurationJSONDecode(t *testing.T) { + f := func(j string, exp time.Duration) { + var act Duration + err := json.Unmarshal([]byte(j), &act) + require.NoError(t, err) + assert.Equal(t, exp, act.TimeDuration()) + } + f(`"5m"`, time.Minute*5) + f(`120`, time.Second*120) + f(`0`, 0) + f(`"1m5s"`, time.Second*65) +} + +func TestOIDJSON(t *testing.T) { + f := func(d OID, exp string) { + bytes, err := json.Marshal(&d) + require.NoError(t, err) + require.Equal(t, exp, string(bytes)) + + var decoded OID + err = json.Unmarshal(bytes, &decoded) + require.NoError(t, err) + + assert.Equal(t, d, decoded) + } + f(OID{1, 12, 1234}, `"1.12.1234"`) +} + +func TestOIDJSONDecode(t *testing.T) { + tcases := []struct { + oid string + err string + }{ + {"1.12.1234", "OID JSON string not wrapped in quotes: 1.12.1234"}, + {"\"1.abc\"", "invalid OID: strconv.Atoi: parsing \"abc\": invalid syntax"}, + } + + oid := new(OID) + for _, tc := range tcases { + err := oid.UnmarshalJSON([]byte(tc.oid)) + require.Error(t, err) + assert.Equal(t, tc.err, err.Error()) + } +}