diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index dfd1b138b..6d2f8bdc0 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -121,6 +121,7 @@ func Backend(conf *logical.BackendConfig) *backend { pathIssue(&b), pathRotateCRL(&b), pathRevoke(&b), + pathRevokeWithKey(&b), pathTidy(&b), pathTidyStatus(&b), diff --git a/builtin/logical/pki/crl_test.go b/builtin/logical/pki/crl_test.go index 88046de8e..4ec54a4a7 100644 --- a/builtin/logical/pki/crl_test.go +++ b/builtin/logical/pki/crl_test.go @@ -420,6 +420,164 @@ func TestBYOC(t *testing.T) { require.NoError(t, err) } +func TestPoP(t *testing.T) { + b, s := createBackendWithStorage(t) + + // Create a root CA. + resp, err := CBWrite(b, s, "root/generate/internal", map[string]interface{}{ + "common_name": "root example.com", + "issuer_name": "root", + "key_type": "ec", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + oldRoot := resp.Data["certificate"].(string) + + // Create a role for issuance. + _, err = CBWrite(b, s, "roles/local-testing", map[string]interface{}{ + "allow_any_name": true, + "enforce_hostnames": false, + "key_type": "ec", + "ttl": "75s", + "no_store": "true", + }) + require.NoError(t, err) + + // Issue a leaf cert and ensure we can revoke it with the private key and + // an explicit certificate. + resp, err = CBWrite(b, s, "issue/local-testing", map[string]interface{}{ + "common_name": "testing1", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": resp.Data["certificate"], + "private_key": resp.Data["private_key"], + }) + require.NoError(t, err) + + // Issue a second leaf, but hold onto it for now. + resp, err = CBWrite(b, s, "issue/local-testing", map[string]interface{}{ + "common_name": "testing2", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + notStoredCert := resp.Data["certificate"].(string) + notStoredKey := resp.Data["private_key"].(string) + + // Update the role to make things stored and issue another cert. + _, err = CBWrite(b, s, "roles/stored-testing", map[string]interface{}{ + "allow_any_name": true, + "enforce_hostnames": false, + "key_type": "ec", + "ttl": "75s", + "no_store": "false", + }) + require.NoError(t, err) + + // Issue a leaf and ensure we can revoke it via serial number and private key. + resp, err = CBWrite(b, s, "issue/stored-testing", map[string]interface{}{ + "common_name": "testing3", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + require.NotEmpty(t, resp.Data["serial_number"]) + require.NotEmpty(t, resp.Data["private_key"]) + + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "serial_number": resp.Data["serial_number"], + "private_key": resp.Data["private_key"], + }) + require.NoError(t, err) + + // Issue a leaf cert and ensure we can revoke it after removing its root; + // hold onto it for now. + resp, err = CBWrite(b, s, "issue/stored-testing", map[string]interface{}{ + "common_name": "testing4", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + storedCert := resp.Data["certificate"].(string) + storedKey := resp.Data["private_key"].(string) + + // Delete the root and regenerate a new one. + _, err = CBDelete(b, s, "issuer/default") + require.NoError(t, err) + + resp, err = CBList(b, s, "issuers") + require.NoError(t, err) + require.Equal(t, len(resp.Data), 0) + + _, err = CBWrite(b, s, "root/generate/internal", map[string]interface{}{ + "common_name": "root2 example.com", + "issuer_name": "root2", + "key_type": "ec", + }) + require.NoError(t, err) + + // Issue a new leaf and revoke that one. + resp, err = CBWrite(b, s, "issue/local-testing", map[string]interface{}{ + "common_name": "testing5", + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.Data["certificate"]) + + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": resp.Data["certificate"], + "private_key": resp.Data["private_key"], + }) + require.NoError(t, err) + + // Now attempt to revoke the earlier leaves. The first should fail since + // we deleted its issuer, but the stored one should succeed. + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": notStoredCert, + "private_key": notStoredKey, + }) + require.Error(t, err) + + // Incorrect combination (stored with not stored key) should fail. + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": storedCert, + "private_key": notStoredKey, + }) + require.Error(t, err) + + // Correct combination (stored with stored) should succeed. + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": storedCert, + "private_key": storedKey, + }) + require.NoError(t, err) + + // Import the old root again and revoke the no stored leaf should work. + _, err = CBWrite(b, s, "issuers/import/bundle", map[string]interface{}{ + "pem_bundle": oldRoot, + }) + require.NoError(t, err) + + // Incorrect combination (not stored with stored key) should fail. + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": notStoredCert, + "private_key": storedKey, + }) + require.Error(t, err) + + // Correct combination (not stored with not stored) should succeed. + _, err = CBWrite(b, s, "revoke-with-key", map[string]interface{}{ + "certificate": notStoredCert, + "private_key": notStoredKey, + }) + require.NoError(t, err) +} + func requestCrlFromBackend(t *testing.T, s logical.Storage, b *backend) *logical.Response { crlReq := &logical.Request{ Operation: logical.ReadOperation, diff --git a/builtin/logical/pki/path_revoke.go b/builtin/logical/pki/path_revoke.go index cb40c4f8e..541a7ae12 100644 --- a/builtin/logical/pki/path_revoke.go +++ b/builtin/logical/pki/path_revoke.go @@ -2,12 +2,17 @@ package pki import ( "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/subtle" "crypto/x509" "encoding/pem" "fmt" "strings" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/certutil" "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/errutil" "github.com/hashicorp/vault/sdk/logical" @@ -44,6 +49,42 @@ signed by an issuer in this mount.`, } } +func pathRevokeWithKey(b *backend) *framework.Path { + return &framework.Path{ + Pattern: `revoke-with-key`, + Fields: map[string]*framework.FieldSchema{ + "serial_number": { + Type: framework.TypeString, + Description: `Certificate serial number, in colon- or +hyphen-separated octal`, + }, + "certificate": { + Type: framework.TypeString, + Description: `Certificate to revoke in PEM format; must be +signed by an issuer in this mount.`, + }, + "private_key": { + Type: framework.TypeString, + Description: `Key to use to verify revocation permission; must +be in PEM format.`, + }, + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.metricsWrap("revoke", noRole, b.pathRevokeWrite), + // This should never be forwarded. See backend.go for more information. + // If this needs to write, the entire request will be forwarded to the + // active node of the current performance cluster, but we don't want to + // forward invalid revoke requests there. + }, + }, + + HelpSynopsis: pathRevokeHelpSyn, + HelpDescription: pathRevokeHelpDesc, + } +} + func pathRotateCRL(b *backend) *framework.Path { return &framework.Path{ Pattern: `crl/rotate`, @@ -63,7 +104,7 @@ func pathRotateCRL(b *backend) *framework.Path { } } -func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *logical.Request, data *framework.FieldData, certPem string) (string, []byte, error) { +func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *logical.Request, certPem string) (string, bool, []byte, error) { // This function handles just the verification of the certificate against // the global issuer set, checking whether or not it is importable. // @@ -74,29 +115,29 @@ func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *log // still using the legacy CA bundle but with the newer certificate // attribute, we err and require the operator to upgrade and migrate // prior to servicing new requests. - return "", nil, errutil.UserError{Err: "unable to process BYOC revocation until CA issuer migration has completed"} + return "", false, nil, errutil.UserError{Err: "unable to process BYOC revocation until CA issuer migration has completed"} } // First start by parsing the certificate. if len(certPem) < 75 { // See note in pathImportIssuers about this check. - return "", nil, errutil.UserError{Err: "provided certificate data was too short; perhaps a path was passed to the API rather than the contents of a PEM file"} + return "", false, nil, errutil.UserError{Err: "provided certificate data was too short; perhaps a path was passed to the API rather than the contents of a PEM file"} } pemBlock, _ := pem.Decode([]byte(certPem)) if pemBlock == nil { - return "", nil, errutil.UserError{Err: "certificate contains no PEM data"} + return "", false, nil, errutil.UserError{Err: "certificate contains no PEM data"} } certReference, err := x509.ParseCertificate(pemBlock.Bytes) if err != nil { - return "", nil, errutil.UserError{Err: fmt.Sprintf("certificate could not be parsed: %v", err)} + return "", false, nil, errutil.UserError{Err: fmt.Sprintf("certificate could not be parsed: %v", err)} } // Ensure we have a well-formed serial number before continuing. serial := serialFromCert(certReference) if len(serial) == 0 { - return "", nil, errutil.UserError{Err: fmt.Sprintf("invalid serial number on presented certificate")} + return "", false, nil, errutil.UserError{Err: fmt.Sprintf("invalid serial number on presented certificate")} } // We have two approaches here: we could start verifying against issuers @@ -110,7 +151,7 @@ func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *log // and if it exists, compare the contents. certEntry, err := fetchCertBySerial(ctx, b, req, req.Path, serial) if err != nil { - return serial, nil, err + return serial, false, nil, err } if certEntry != nil { @@ -119,43 +160,43 @@ func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *log // from the raw data. certReferenceStored, err := x509.ParseCertificate(certEntry.Value) if err != nil { - return serial, nil, err + return serial, false, nil, err } if !areCertificatesEqual(certReference, certReferenceStored) { // Here we refuse the import with an error because the two certs // are unequal but we would've otherwise overwritten the existing // copy. - return serial, nil, fmt.Errorf("certificate with same serial but unequal value already present in this cluster's storage; refusing to revoke") + return serial, false, nil, fmt.Errorf("certificate with same serial but unequal value already present in this cluster's storage; refusing to revoke") } else { // Otherwise, we can return without an error as we've already // imported this certificate, likely when we issued it. We don't // need to re-verify the signature as we assume it was already // verified when it was imported. - return serial, nil, nil + return serial, false, certEntry.Value, nil } } // Otherwise, we must not have a stored copy. From here on out, the second - // parameter (except in error cases) should be the copy to write out. + // parameter (except in error cases) should cause the cert to write out. // // Fetch and iterate through each issuer. sc := b.makeStorageContext(ctx, req.Storage) issuers, err := sc.listIssuers() if err != nil { - return serial, nil, err + return serial, false, nil, err } foundMatchingIssuer := false for _, issuerId := range issuers { issuer, err := sc.fetchIssuerById(issuerId) if err != nil { - return serial, nil, err + return serial, false, nil, err } issuerCert, err := issuer.GetCertificate() if err != nil { - return serial, nil, err + return serial, false, nil, err } if err := certReference.CheckSignatureFrom(issuerCert); err == nil { @@ -167,10 +208,85 @@ func (b *backend) pathRevokeWriteHandleCertificate(ctx context.Context, req *log } if foundMatchingIssuer { - return serial, certReference.Raw, nil + return serial, true, certReference.Raw, nil } - return serial, nil, errutil.UserError{Err: fmt.Sprintf("unable to verify signature on presented cert from any present issuer in this mount; certificates from previous CAs will need to have their issuing CA and key re-imported if revocation is necessary")} + return serial, false, nil, errutil.UserError{Err: fmt.Sprintf("unable to verify signature on presented cert from any present issuer in this mount; certificates from previous CAs will need to have their issuing CA and key re-imported if revocation is necessary")} +} + +func (b *backend) pathRevokeWriteHandleKey(ctx context.Context, req *logical.Request, cert []byte, keyPem string) error { + if keyPem == "" { + // The only way to get here should be via the /revoke endpoint; + // validate the path one more time and return an error if necessary. + if req.Path != "revoke" { + return fmt.Errorf("must have private key to revoke via the /revoke-with-key path") + } + + // Otherwise, we don't need to validate the key and thus can return + // with success. + return nil + } + + // Parse the certificate for reference. + certReference, err := x509.ParseCertificate(cert) + if err != nil { + return errutil.UserError{Err: fmt.Sprintf("certificate could not be parsed: %v", err)} + } + + // Now parse the key's PEM block. + pemBlock, _ := pem.Decode([]byte(keyPem)) + if pemBlock == nil { + return errutil.UserError{Err: "provided key PEM block contained no data or failed to parse"} + } + + // Parse the inner DER key. + signer, _, err := certutil.ParseDERKey(pemBlock.Bytes) + if err != nil { + return fmt.Errorf("failed to parse provided private key: %v", err) + } + + // Finally, verify if the cert and key match. This code has been + // cribbed from the Go TLS config code, with minor modifications. + // + // In particular, we validate against the derived public key + // components and ensure we validate exponent and curve information + // as well. + // + // + // See: https://github.com/golang/go/blob/c6a2dada0df8c2d75cf3ae599d7caed77d416fa2/src/crypto/tls/tls.go#L304-L331 + switch certPub := certReference.PublicKey.(type) { + case *rsa.PublicKey: + privPub, ok := signer.Public().(*rsa.PublicKey) + if !ok { + return errutil.UserError{Err: "provided private key type does not match certificate's public key type"} + } + if err := signer.(*rsa.PrivateKey).Validate(); err != nil { + return err + } + if certPub.N.Cmp(privPub.N) != 0 || certPub.E != privPub.E { + return errutil.UserError{Err: "provided private key does not match certificate's public key"} + } + case *ecdsa.PublicKey: + privPub, ok := signer.Public().(*ecdsa.PublicKey) + if !ok { + return errutil.UserError{Err: "provided private key type does not match certificate's public key type"} + } + if certPub.X.Cmp(privPub.X) != 0 || certPub.Y.Cmp(privPub.Y) != 0 || certPub.Params().Name != privPub.Params().Name { + return errutil.UserError{Err: "provided private key does not match certificate's public key"} + } + case ed25519.PublicKey: + privPub, ok := signer.Public().(ed25519.PublicKey) + if !ok { + return errutil.UserError{Err: "provided private key type does not match certificate's public key type"} + } + if subtle.ConstantTimeCompare(privPub, certPub) == 0 { + return errutil.UserError{Err: "provided private key does not match certificate's public key"} + } + default: + return errutil.UserError{Err: "certificate has an unknown public key algorithm; unable to validate provided private key; ask an admin to revoke this certificate instead"} + } + + return nil } func (b *backend) pathRevokeWrite(ctx context.Context, req *logical.Request, data *framework.FieldData, _ *roleEntry) (*logical.Response, error) { @@ -183,26 +299,71 @@ func (b *backend) pathRevokeWrite(ctx context.Context, req *logical.Request, dat return logical.ErrorResponse("Must provide either the certificate or the serial to revoke; not both."), nil } + var keyPem string + if req.Path == "revoke-with-key" { + rawKey, haveKey := data.GetOk("private_key") + if !haveKey { + return logical.ErrorResponse("Must have private key to revoke via the /revoke-with-key path."), nil + } + + keyPem = rawKey.(string) + if len(keyPem) < 64 { + // See note in pathImportKeyHandler... + return logical.ErrorResponse("Provided data for private_key was too short; perhaps a path was passed to the API rather than the contents of a PEM file?"), nil + } + } + var serial string if haveSerial { // Easy case: this cert should be in storage already. serial = rawSerial.(string) + if len(serial) == 0 { + return logical.ErrorResponse("The serial number must be provided"), nil + } + + // Here, fetch the certificate from disk to validate we can revoke it. + certEntry, err := fetchCertBySerial(ctx, b, req, req.Path, serial) + if err != nil { + switch err.(type) { + case errutil.UserError: + return logical.ErrorResponse(err.Error()), nil + default: + return nil, err + } + } + if certEntry == nil { + return logical.ErrorResponse(fmt.Sprintf("certificate with serial %s not found or was already revoked", serial)), nil + } + + // Now, if the user provided a key, we'll have to make sure the key + // and stored certificate match. + if err := b.pathRevokeWriteHandleKey(ctx, req, certEntry.Value, keyPem); err != nil { + return nil, err + } } else { // Otherwise, we've gotta parse the certificate from the request and // then import it into cluster-local storage. Before writing the // certificate (and forwarding), we want to verify this certificate // was actually signed by one of our present issuers. var err error + var writeCert bool var certBytes []byte - serial, certBytes, err = b.pathRevokeWriteHandleCertificate(ctx, req, data, rawCertificate.(string)) + serial, writeCert, certBytes, err = b.pathRevokeWriteHandleCertificate(ctx, req, rawCertificate.(string)) if err != nil { return nil, err } + // Before we write the certificate, we've gotta verify the request in + // the event of a PoP-based revocation scheme; we don't want to litter + // storage with issued-but-not-revoked certificates. + if err := b.pathRevokeWriteHandleKey(ctx, req, certBytes, keyPem); err != nil { + return nil, err + } + // At this point, a forward operation will occur if we're on a standby // node as we're now attempting to write the bytes of the cert out to // disk. - if certBytes != nil { + if writeCert { err = req.Storage.Put(ctx, &logical.StorageEntry{ Key: "certs/" + serial, Value: certBytes, @@ -215,10 +376,6 @@ func (b *backend) pathRevokeWrite(ctx context.Context, req *logical.Request, dat // Finally, we have a valid serial number to use for BYOC revocation! } - if len(serial) == 0 { - return logical.ErrorResponse("The serial number must be provided"), nil - } - // Assumption: this check is cheap. Call this twice, in the cert-import // case, to allow cert verification to get rejected on the standby node, // but we still need it to protect the serial number case. @@ -258,11 +415,15 @@ func (b *backend) pathRotateCRLRead(ctx context.Context, req *logical.Request, _ } const pathRevokeHelpSyn = ` -Revoke a certificate by serial number. +Revoke a certificate by serial number or with explicit certificate. + +When calling /revoke-with-key, the private key corresponding to the +certificate must be provided to authenticate the request. ` const pathRevokeHelpDesc = ` -This allows certificates to be revoked using its serial number. A root token is required. +This allows certificates to be revoke. A root token or corresponding +private key is required. ` const pathRotateCRLHelpSyn = ` diff --git a/changelog/16566.txt b/changelog/16566.txt new file mode 100644 index 000000000..269d8dafb --- /dev/null +++ b/changelog/16566.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Allow revocation via proving possession of certificate's private key +``` diff --git a/sdk/helper/certutil/helpers.go b/sdk/helper/certutil/helpers.go index b69eb36aa..ececbad81 100644 --- a/sdk/helper/certutil/helpers.go +++ b/sdk/helper/certutil/helpers.go @@ -176,18 +176,21 @@ func ParsePKIJSON(input []byte) (*ParsedCertBundle, error) { } func ParseDERKey(privateKeyBytes []byte) (signer crypto.Signer, format BlockType, err error) { - if signer, err = x509.ParseECPrivateKey(privateKeyBytes); err == nil { + var firstError error + if signer, firstError = x509.ParseECPrivateKey(privateKeyBytes); firstError == nil { format = ECBlock return } - if signer, err = x509.ParsePKCS1PrivateKey(privateKeyBytes); err == nil { + var secondError error + if signer, secondError = x509.ParsePKCS1PrivateKey(privateKeyBytes); secondError == nil { format = PKCS1Block return } + var thirdError error var rawKey interface{} - if rawKey, err = x509.ParsePKCS8PrivateKey(privateKeyBytes); err == nil { + if rawKey, thirdError = x509.ParsePKCS8PrivateKey(privateKeyBytes); thirdError == nil { switch rawSigner := rawKey.(type) { case *rsa.PrivateKey: signer = rawSigner @@ -203,7 +206,7 @@ func ParseDERKey(privateKeyBytes []byte) (signer crypto.Signer, format BlockType return } - return nil, UnknownBlock, err + return nil, UnknownBlock, fmt.Errorf("got errors attempting to parse DER private key:\n1. %v\n2. %v\n3. %v", firstError, secondError, thirdError) } func ParsePEMKey(keyPem string) (crypto.Signer, BlockType, error) { diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index 24f4cbfff..e2cc81eef 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -28,6 +28,7 @@ update your API calls accordingly. - [Sign Self-Issued](#sign-self-issued) - [Sign Verbatim](#sign-verbatim) - [Revoke Certificate](#revoke-certificate) + - [Revoke Certificate with Private Key](#revoke-certificate-with-private-key) - [Accessing Authority Information](#accessing-authority-information) - [List Issuers](#list-issuers) - [Read Issuer Certificate](#read-issuer-certificate) @@ -872,6 +873,74 @@ $ curl \ } ``` +### Revoke Certificate with Private Key + +This endpoint revokes a certificate using its private key as proof that the +request is authorized by an appropriate individual (Proof of Possession). + +This is an alternative option to the standard method of revoking using Vault +lease IDs or revocation via serial number. A successful revocation will +rotate the CRL. + +It is not possible to revoke issuers using this path. + +~> **Note**: This operation is **NOT** privileged, as it validates revocation + has a private key corresponding to a certificate signed by Vault. However, + to avoid third parties performing a denial-of-service (DOS) against Vault, + we've made this endpoint authenticated. Thus it is strongly encouraged to + generally allow all access to this path via ACLs. + +| Method | Path | +| :----- | :--------------------- | +| `POST` | `/pki/revoke-with-key` | + +#### Parameters + +~> Note: either `serial_number` or `certificate` (but not both) must be + specified on requests to this endpoint. + +- `serial_number` `(string: )` - Specifies the serial number of the + certificate to revoke, in hyphen-separated or colon-separated hexadecimal. + +- `certificate` `(string: )` - Specifies the certificate to revoke, + in PEM format. This certificate must have been signed by one of the issuers + in this mount in order to be accepted for revocation. + +- `private_key` `(string: )` - Specifies the private key (in PEM + format) corresponding to the certificate issued by Vault that is attempted + to be revoked. This endpoint must be called several times (with each unique + certificate/serial number) if this private key is used in multiple + certificates as Vault does not maintain such a mapping. + +#### Sample Payload + +```json +{ + "serial_number": "39:dd:2e...", + "private_key": "-----BEGIN PRIVATE KEY-----\n..." +} +``` + +#### Sample Request + +```shell-session +$ curl \ + --header "X-Vault-Token: ..." \ + --request POST \ + --data @payload.json \ + http://127.0.0.1:8200/v1/pki/revoke-with-key +``` + +#### Sample Response + +```json +{ + "data": { + "revocation_time": 1433269787 + } +} +``` + --- ## Accessing Authority Information diff --git a/website/content/docs/secrets/pki/considerations.mdx b/website/content/docs/secrets/pki/considerations.mdx index ff11410ee..2ec58ae7a 100644 --- a/website/content/docs/secrets/pki/considerations.mdx +++ b/website/content/docs/secrets/pki/considerations.mdx @@ -423,6 +423,7 @@ For these personas, we suggest the following ACLs, in condensed, tabular form: | `/issuer/:issuer_ref/(json¦der¦pem)` | Read | Yes | Yes | Yes | Yes | Yes | | `/issuer/:issuer_ref/crl(/der¦/pem)?` | Read | Yes | Yes | Yes | Yes | Yes | | `/certs` | List | Yes | Yes | Yes | Yes | | +| `/revoke-with-key` | Write | Yes | Yes | Yes | Yes | | | `/roles` | List | Yes | Yes | Yes | Yes | | | `/roles/:role` | Read | Yes | Yes | Yes | Yes | | | `/(issue¦sign)/:role` | Write | Yes | Yes | Yes | Yes | |