Skip to content

Commit

Permalink
Feature: allow to revoke or disable device CA (#324)
Browse files Browse the repository at this point in the history
Currently, an API allows to simply remove the device CA from a list of CAs.

This change implements the device CA removal via the X.509 certificate revocation.
That way, a user is required to crypto-sign the operation using the factory root CA.
Thus, we both obtain an cryptographic conscent from the user for this operation and protect against several scenarios:

- An accidental device CA removal by passing a wrong file to "keys ca update".
- An intentional device CA removal by bad admin who does not possess the factory root CA.

After this change, the API will be changed to require the "ca-crt" field to contain all device CAs which:

- Are already present in the "ca-crt" list on the backend.
- And are not explicitly listed in the "ca-revoke-crl" list.

There are two new operations introduced:

- Disable device CA: Tell the backend to disallow registering new devices using this CA; already registered devices (using this CA) can still connect and use FoundriesFactory.
- Revoke device CA: Tell the backend to remove this CA from the list of trusted device CAs; devices with client certs issued by this CA can no longer connect and use FoundriesFactory.

Signed-off-by: Volodymyr Khoroz volodymyr.khoroz@foundries.io
  • Loading branch information
vkhoroz committed Nov 14, 2023
1 parent 3ba8a98 commit f3a96bd
Show file tree
Hide file tree
Showing 8 changed files with 420 additions and 55 deletions.
3 changes: 3 additions & 0 deletions client/foundries_pki.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type CaCerts struct {
EstCrt string `json:"est-tls-crt,omitempty"`
TlsCrt string `json:"tls-crt,omitempty"`

CaRevokeCrl string `json:"ca-revoke-crl,omitempty"`
CaDisabled []string `json:"disabled-ca-serials,omitempty"` // readonly

ChangeMeta ChangeMeta `json:"change-meta"`
}

Expand Down
235 changes: 235 additions & 0 deletions subcommands/keys/ca_revoke_device_ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package keys

import (
x509Lib "crypto/x509"
"encoding/asn1"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/foundriesio/fioctl/client"
"github.com/foundriesio/fioctl/subcommands"
"github.com/foundriesio/fioctl/x509"
)

const (
crlCmdAnnotation = "crl-revoke"
crlCmdRevokeDeviceCa = "revoke-device-ca"
crlCmdDisableDeviceCa = "disable-device-ca"
)

func init() {
revokeCmd := &cobra.Command{
Use: "revoke-device-ca <PKI Directory>",
Short: "Revoke device CA, so that devices with client certificates it issued can no longer connect to your Factory",
Run: doRevokeDeviceCa,
Args: cobra.ExactArgs(1),
Long: `Revoke device CA, so that devices with client certificates it issued can no longer connect to your Factory.
When the online or local device CA is revoked:
- It is no longer possible to register new devices with client certificates it had issued.
- Existing devices with client certificates it issued can no longer connect to your Factory.
You may later re-add a revoked device CA using "keys ca update", if you still keep its certificate stored somewhere.
Once you do this, devices with client certificates issued by this device CA may connect to your Factory again.`,
Example: `
# Revoke a local device CA by providing a (default) file name inside your PKI directory:
fioctl keys ca revoke-device-ca /path/to/pki/dir --ca-file local-ca
# Revoke two local device CAs given a full path to their files:
fioctl keys ca revoke-device-ca /path/to/pki/dir --ca-file /path/to/ca1.pem --ca-file /path/to/ca2.crt
# Revoke two device CAs given their serial numbers:
fioctl keys ca revoke-device-ca /path/to/pki/dir --ca-serial <base-10-serial-1> --ca-file <base-10-serial-2>
# Revoke a local device CA, when your factory root CA private key is stored on an HSM:
fioctl keys ca revoke-device-ca /path/to/pki/dir --ca-file local-ca \
--hsm-module /path/to/pkcs11-module.so --hsm-pin 1234 --hsm-token-label <token-label-for-key>
# Show a generated CRL that would be sent to the server to revoke a local device CA, without actually revoking it.
fioctl keys ca revoke-device-ca /path/to/pki/dir --ca-file local-ca --dry-run --pretty`,
Annotations: map[string]string{crlCmdAnnotation: crlCmdRevokeDeviceCa},
}
caCmd.AddCommand(revokeCmd)
addRevokeCmdFlags(revokeCmd, "revoke")

disableCmd := &cobra.Command{
Use: "disable-device-ca <PKI Directory>",
Short: "Disable device CA, so that new devices with client certificates it issued can no longer be registered",
Run: doRevokeDeviceCa,
Args: cobra.ExactArgs(1),
Long: `Disable device CA, so that new devices with client certificates it issued can no longer be registered.
When the online or local device CA is disabled:
- It is no longer possible to register new devices with client certificates it had issued.
- Existing devices with client certificates it issued may continue to connect and use your Factory.
Usually, when the device CA is compromised, a user should:
1. Immediately disable a given device CA using "fioctl keys ca disable-device-ca <PKI Directory> --serial <CA Serial>".
2. Inspect their devices with client certificates issued by that device CA, and remove compromised devices (see "fioctl devices list|delete").
3. Create a new device CA using "fioctl keys ca add-device-ca <PKI Directory> --online-ca|--local-ca".
4. Rotate a client certificate of legitimate devices to the certificate issued by a new device CA (see "fioctl devices config rotate-certs").
5. Revoke a given device CA using "fioctl keys ca revoke-device-ca <PKI Directory> --serial <CA Serial>".`,
Example: `
# Disable two device CAs given their serial numbers:
fioctl keys ca disable-device-ca /path/to/pki/dir --ca-serial <base-10-serial-1> --ca-file <base-10-serial-2>
# Show a generated CRL that would be sent to the server to disable a local device CA, without actually disabling it.
fioctl keys ca disable-device-ca /path/to/pki/dir --ca-file local-ca --dry-run --pretty
# See "fioctl keys ca revoke-device-ca --help" for more examples; these two commands have a very similar syntax.`,
Annotations: map[string]string{crlCmdAnnotation: crlCmdDisableDeviceCa},
}
caCmd.AddCommand(disableCmd)
addRevokeCmdFlags(disableCmd, "disable")
}

func addRevokeCmdFlags(cmd *cobra.Command, op string) {
cmd.Flags().BoolP("dry-run", "", false,
"Do not "+op+" the certificate, but instead show a generated CRL that will be uploaded to the server.")
cmd.Flags().BoolP("pretty", "", false,
"Can be used with dry-run to show the generated CRL in a pretty format.")
cmd.Flags().StringArrayP("ca-file", "", nil,
"A file name of the device CA to "+op+". Can be used multiple times to "+op+" several device CAs")
cmd.Flags().StringArrayP("ca-serial", "", nil,
"A serial number (base 10) of the device CA to "+op+". Can be used multiple times to "+op+" several device CAs")
_ = cmd.MarkFlagFilename("ca-file")
// HSM variables defined in ca_create.go
cmd.Flags().StringVarP(&hsmModule, "hsm-module", "", "", "Load a root CA key from a PKCS#11 compatible HSM using this module")
cmd.Flags().StringVarP(&hsmPin, "hsm-pin", "", "", "The PKCS#11 PIN to log into the HSM")
cmd.Flags().StringVarP(&hsmTokenLabel, "hsm-token-label", "", "", "The label of the HSM token containing the root CA key")
}

func doRevokeDeviceCa(cmd *cobra.Command, args []string) {
factory := viper.GetString("factory")
dryRun, _ := cmd.Flags().GetBool("dry-run")
pretty, _ := cmd.Flags().GetBool("pretty")
caFiles, _ := cmd.Flags().GetStringArray("ca-file")
caSerials, _ := cmd.Flags().GetStringArray("ca-serial")
crlReason := map[string]int{
crlCmdRevokeDeviceCa: x509.CrlCaRevoke,
crlCmdDisableDeviceCa: x509.CrlCaDisable,
}[cmd.Annotations[crlCmdAnnotation]]

if len(caFiles)+len(caSerials) == 0 {
subcommands.DieNotNil(errors.New("At least one of --ca-file or --ca-serial must be provided"))
}

subcommands.DieNotNil(os.Chdir(args[0]))
hsm, err := x509.ValidateHsmArgs(
hsmModule, hsmPin, hsmTokenLabel, "--hsm-module", "--hsm-pin", "--hsm-token-label")
subcommands.DieNotNil(err)
x509.InitHsm(hsm)

fmt.Println("Generating Certificate Revocation List")
toRevoke := make(map[string]int, len(caSerials)+len(caFiles))
for _, serial := range caSerials {
num := new(big.Int)
if _, ok := num.SetString(serial, 10); !ok {
subcommands.DieNotNil(fmt.Errorf("Value %s is not a valid base 10 serial", serial))
}
toRevoke[serial] = crlReason
}
for _, filename := range caFiles {
ca := x509.LoadCertFromFile(filename)
toRevoke[ca.SerialNumber.Text(10)] = crlReason
}

caList, err := api.FactoryGetCA(factory)
subcommands.DieNotNil(err)
validSerials := make(map[string]bool, 0)
for _, c := range parseCertList(caList.CaCrt) {
validSerials[c.SerialNumber.Text(10)] = true
}
for serial := range toRevoke {
if _, ok := validSerials[serial]; !ok {
subcommands.DieNotNil(fmt.Errorf("There is no actual device CA with serial %s", serial))
}
}

fmt.Println("Signing CRL by factory root CA")
certs := client.CaCerts{CaRevokeCrl: x509.CreateCrl(toRevoke)}

if dryRun {
fmt.Println(certs.CaRevokeCrl)
if pretty {
prettyPrintCrl(certs.CaRevokeCrl)
}
return
}

fmt.Println("Uploading CRL to Foundries.io")
subcommands.DieNotNil(api.FactoryPatchCA(factory, certs))
}

func prettyPrintCrl(crlPem string) {
block, remaining := pem.Decode([]byte(crlPem))
if block == nil || len(remaining) > 0 {
subcommands.DieNotNil(errors.New("Invalid PEM block"), "Failed to parse generated CRL:")
return // linter
}
c, err := x509Lib.ParseRevocationList(block.Bytes)
subcommands.DieNotNil(err, "Failed to parse generated CRL:")
fmt.Println("Certificate Revocation List:")
fmt.Println("\tIssuer:", c.Issuer)
fmt.Println("\tValidity:")
fmt.Println("\t\tNot Before:", c.ThisUpdate)
fmt.Println("\t\tNot After:", c.NextUpdate)
fmt.Println("\tSignature Algorithm:", c.SignatureAlgorithm)
fmt.Println("\tSignature:", hex.EncodeToString(c.Signature))
fmt.Println("\tRevoked Certificates:")
for _, crt := range c.RevokedCertificates {
fmt.Println("\t\tSerial:", crt.SerialNumber.Text(10))
fmt.Println("\t\t\tRevocation Date:", crt.RevocationTime)
if len(crt.Extensions) > 0 {
fmt.Println("\t\t\tExtensions:")
for _, ext := range crt.Extensions {
if ext.Id.String() == "2.5.29.21" {
fmt.Print("\t\t\t\tx509v3 Reason Code:")
if ext.Critical {
fmt.Print("(critical)")
}
var val asn1.Enumerated
if _, err := asn1.Unmarshal(ext.Value, &val); err != nil {
fmt.Println(err)
} else {
readable := map[int]string{
x509.CrlCaRevoke: "Revoke",
x509.CrlCaDisable: "Disable",
x509.CrlCaRenew: "Renew",
}[int(val)]
fmt.Println("\n\t\t\t\t\t", readable, "-", val)
}
} else {
fmt.Println("\t\t\t\tUnknown OID", ext.Id.String())
}
}
}
}
if len(c.Extensions) > 0 {
fmt.Println("\tExtensions:")
for _, ext := range c.Extensions {
if ext.Id.String() == "2.5.29.35" {
fmt.Print("\t\tx509v3 Authority Key Id: ")
if ext.Critical {
fmt.Print("(critical)")
}
fmt.Println("\n\t\t\t", hex.EncodeToString(c.AuthorityKeyId))
} else if ext.Id.String() == "2.5.29.20" {
fmt.Print("\t\tx509v3 CRL Number: ")
if ext.Critical {
fmt.Print("(critical)")
}
fmt.Println("\n\t\t\t", c.Number)
} else {
fmt.Println("\t\tUnknown OID", ext.Id.String())
}
}
}
}
88 changes: 54 additions & 34 deletions subcommands/keys/ca_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import (
"github.com/foundriesio/fioctl/subcommands"
)

const (
justShowRoot = "just-root"
justShowTls = "just-tls"
justShowCas = "just-device-cas"
)

var (
prettyFormat bool
justShowFlags subcommands.MutuallyExclusiveFlags
Expand All @@ -30,9 +36,9 @@ func init() {
}
caCmd.AddCommand(cmd)
cmd.Flags().BoolVarP(&prettyFormat, "pretty", "", false, "Display human readable output of each certificate")
justShowFlags.Add(cmd, "just-root", "Only show the Factory root CA certificate")
justShowFlags.Add(cmd, "just-tls", "Only show the device-gateway TLS certificate")
justShowFlags.Add(cmd, "just-device-cas", "Only show device authenticate certificates trusted by the device-gateway")
justShowFlags.Add(cmd, justShowRoot, "Only show the Factory root CA certificate")
justShowFlags.Add(cmd, justShowTls, "Only show the device-gateway TLS certificate")
justShowFlags.Add(cmd, justShowCas, "Only show device authenticate certificates trusted by the device-gateway")
}

func doShowCA(cmd *cobra.Command, args []string) {
Expand All @@ -44,19 +50,17 @@ func doShowCA(cmd *cobra.Command, args []string) {

flag, err := justShowFlags.GetFlag()
subcommands.DieNotNil(err)
justVal := ""
if flag == "just-root" {
justVal = resp.RootCrt
} else if flag == "just-tls" {
justVal = resp.TlsCrt
} else if flag == "just-device-cas" {
justVal = resp.CaCrt
}
if len(justVal) > 0 {
if prettyFormat {
prettyPrint(justVal)
} else {
fmt.Println(justVal)
if len(flag) > 0 {
switch flag {
case justShowRoot:
printOneCert(resp.RootCrt)
case justShowTls:
printOneCert(resp.TlsCrt)
case justShowCas:
printOneCert(resp.CaCrt)
printDisabledCas(resp.CaDisabled)
default:
panic("Unknown flag: " + flag)
}
return
}
Expand All @@ -74,22 +78,28 @@ func doShowCA(cmd *cobra.Command, args []string) {
}

fmt.Println("## Factory root certificate")
if prettyFormat {
prettyPrint(resp.RootCrt)
} else {
fmt.Println(resp.RootCrt)
}
printOneCert(resp.RootCrt)
fmt.Println("## Server TLS Certificate")
if prettyFormat {
prettyPrint(resp.TlsCrt)
} else {
fmt.Println(resp.TlsCrt)
}
printOneCert(resp.TlsCrt)
fmt.Println("\n## Device Authentication Certificate(s)")
printOneCert(resp.CaCrt)
printDisabledCas(resp.CaDisabled)
}

func printOneCert(crt string) {
if prettyFormat {
prettyPrint(resp.CaCrt)
prettyPrint(crt)
} else {
fmt.Println(resp.CaCrt)
fmt.Println(crt)
}
}

func printDisabledCas(serials []string) {
if len(serials) > 0 {
fmt.Println("\n## Disabled Device Authentication Certificate Serial(s)")
for _, num := range serials {
fmt.Println(" - ", num)
}
}
}

Expand Down Expand Up @@ -142,23 +152,33 @@ func extKeyUsage(ext []x509.ExtKeyUsage) string {
return vals
}

func prettyPrint(cert string) {
for len(cert) > 0 {
block, remaining := pem.Decode([]byte(cert))
func parseCertList(pemData string) (certs []*x509.Certificate) {
for len(pemData) > 0 {
block, remaining := pem.Decode([]byte(pemData))
if block == nil {
// could be excessive whitespace
cert = strings.TrimSpace(string(remaining))
if pemData = strings.TrimSpace(string(remaining)); len(pemData) == len(remaining) {
fmt.Println("Failed to parse remaining certificates: invalid PEM data")
break
}
continue
}
cert = string(remaining)
pemData = string(remaining)
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
fmt.Println("Failed to parse certificate:" + err.Error())
continue
}
certs = append(certs, c)
}
return
}

func prettyPrint(cert string) {
for _, c := range parseCertList(cert) {
fmt.Println("Certificate:")
fmt.Println("\tVersion:", c.Version)
fmt.Println("\tSerial Number:", c.SerialNumber)
fmt.Println("\tSerial Number:", c.SerialNumber.Text(10))
fmt.Println("\tSignature Algorithm:", c.SignatureAlgorithm)
fmt.Println("\tIssuer:", c.Issuer)
fmt.Println("\tValidity")
Expand Down
Loading

0 comments on commit f3a96bd

Please sign in to comment.