Hansel Inside the Computer

About a month ago, I wrote a post about using my MiniLab Module to easily deploy a new Root and Issuing Certificate Authority (CA) to a Windows Domain using Windows VMs. I was able to simplify things to the point where running one function would take care of everything for you. Unfortunately, even though actually using the function (Create-TwoTierPKI) took about 2 seconds, the operations the function handled (i.e. deploying and configuring Windows VMs to become the new Root and Issuing CAs) took about 3 hours - which is a really long time. So, I wanted to write a post about how to use PowerShell Core, Docker For Windows, and CloudFlare’s CFSSL to turn 3 hours into about 30 minutes (although, you have to do all of the steps yourself as opposed to running a function and doing something else for 3 hours).

Let’s Dive In

  • Log into your Windows 10, Windows 2012 R2, or Windows 2016 machine that is/will become our Hyper-V hypervisor.
  • Launch Windows PowerShell 5.1 (WinPS) or PowerShell Core 6.X (PSCore) and make sure you have the latest PowerShell Core installed. The easiest way to do this is to use the ProgramManagement Module (full disclosure: I’m the Module’s author).
Install-Module ProgramManagement
Import-Module ProgramManagement
$InstallPwshResult = Install-Program -ProgramName powershell-core -CommandName pwsh.exe -PreRelease -ExpectedInstallLocation "C:\Program Files\PowerShell"
  • Launch the latest version of PSCore (Run as Administrator)

IMPORTANT NOTE: If you used WinPS (as opposed to PSCore) when running the above Install-ProgramManagement and Import-ProgramManagement commands, you will need to do so again in PSCore.

  • Install OpenSSH-Win64
$InstallOpenSSHResult = Install-Program -ProgramName openssh -CommandName ssh.exe -ExpectedInstallLocation "C:\Program Files\OpenSSH-Win64"
  • Now ‘ssh.exe’ should be available in your current PSCore Session:
PS C:\Users\zeroadmin> Get-Command ssh

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     ssh.exe                                            7.7.1.0    C:\Program Files\OpenSSH-Win64\ssh.exe
  • Install and Import the MiniLab Module

IMPORTANT NOTE: Since the the MiniLab Module was originally written for WinPS (as opposed to PSCore), importing it within PSCore could take up to 5 minutes (as opposed to ~45 seconds in WinPS) due to use of the WindowsCompatibility Module’s implict remoting.

Install-Module MiniLab
Import-Module MiniLab
  • Install Docker For Windows (also known as Docker CE) by using the MiniLab Module’s Install-Docker function

IMPORTANT NOTE: If Hyper-V is not already installed on the localhost, the Install-Docker function will install it, and you will most likely need to restart the localhost, re-launch PSCore, re-import the MiniLab Module, and re-run the Install-Docker function.

IMPORTANT NOTE: At the conclusion of the Docker CE install, you will need to logout and log back in. This is a limitation of how Docker CE handles security groups on Windows.

IMPORTANT NOTE: If your localhost is a Guest VM (as opposed to baremetal) and you either do not have access to the Hyper-V hypervisor managing your localhost, or if the hypervisor is not Hyper-V, be sure to use the -TryWithoutHypervisorInfo with the Install-Docker function. If you DO have have access to the Hyper-V hypervisor managing your localhost, but you need to specify credentials other than your currently logged in account to access it, then use the -HypervisorCreds parameter.

Install-Docker
  • After logging back into the localhost post Docker CE install, re-launch PSCore (Run as Administrator), and re-import the MiniLab Module via Import-Module Minilab
  • OPTIONAL: At this point, I highly recommend changing default Docker storage directories to drives other than C:\. To do this, you can use the MiniLab Module’s Move-DockerStorage function.

IMPORTANT NOTE: If this is a fresh install, when using the Move-DockerStorage function, you will receive a GUI pop-up with information about LCOW. You will have to click ‘OK’ manually to let the Move-DockerStorage function proceed. There is an open issue on GitHub to try and get rid of this pop-up: https://github.com/docker/for-win/issues/1875

NOTE: Be sure to create the below -CustomWindowsImageStoragePath and -CustomLinuxImageStoragePath directory paths before using the Move-DockerStorage function. (Replace the paths with something appropriate for your environment.)

$MoveDockerStorageSplatParams = @{
    CustomWindowsImageStoragePath   = "H:\DockerStorage\WindowsContainers"
    CustomLinuxImageStoragePath     = "H:\DockerStorage\LinuxContainers"
    Force                           = $True
}
Move-DockerStorage @MoveDockerStorageSplatParams
  • Create a CentOS 7 Virtual Machine that will become our dedicated Linux Docker Machine. (This VM is VERY lightweight - less than 1GB on filesystem - which is smaller than most Windows Container Images - and it can run perfectly fine with 1 CPU and 1024MB Memory)

NOTE: In my experience with Docker For Windows, I’ve run into situations where the default Linux VM that handles Linux Containers (i.e. MobyLinuxVM) needs to be recreated. When this happens, any Linux Container Images that you previously downloaded will be destroyed. So, I’ve found that creating a dedicated Linux VM separate from our Docker For Windows installation is more resilient at the end of the day.

$VMName = "CentOS7Docker"
$VMDestDir = "H:\VirtualMachines"
$DeployHyperVVagrantBoxSplatParams = @{
    VagrantBox              = "centos/7"
    VagrantProvider         = "hyperv"
    VMName                  = $VMName
    VMDestinationDirectory  = $VMDestDir
    Memory                  = 2048
    CPUs                    = 1
}
$DeployCentOS7Result = Deploy-HyperVVagrantBoxManually @DeployHyperVVagrantBoxSplatParams
$CentOSIP = $DeployCentOS7Result.VMIPAddress

NOTE: The above Deploy-HyperVVagrantBoxManually function - aside from deploying the CentOS7 VM - also downloads ‘vagrant_unsecure_key’ and ‘vagrant_unsecure_key.pub’ from https://github.com/hashicorp/vagrant/tree/master/keys and places them under “$HOME.ssh”.

  • Rename our CentOS7 VM to match the VM Name ‘CentOS7Docker’

IMPORTANT NOTE: If you would like to be able to resolve ‘CentOS7Docker’ via HostName/FQDN (as opposed to IP), you will need to update your DNS Records (which is covered later on). If you intend to update your DNS Records, ensure that the relevant domain is appended to the hostname. This tutorial will assume that you intend to update your DNS Records.

$DomainName = "zero.lab"
$VMNameLowerCase = $VMName.ToLowerInvariant()
$DomainNameLowerCase = $DomainName.ToLowerInvariant()
$RenameCentOSHostScript = @"
sudo hostnamectl set-hostname $VMNameLowerCase.$DomainNameLowerCase --static
sudo sed -i '/localhost/ s/`$/ $VMNameLowerCase $VMNameLowerCase.$DomainNameLowerCase/' /etc/hosts
"@
ssh -o "StrictHostKeyChecking=no" -o "IdentitiesOnly=yes" -i "$HOME\.ssh\vagrant_unsecure_key" -t vagrant@$CentOSIP "$RenameCentOSHostScript"
  • Let’s turn our CentOS7 VM into a Docker Machine!

NOTE: The below docker-machine create might take a couple of minutes.

ssh -o "StrictHostKeyChecking=no" -o "IdentitiesOnly=yes" -i "$HOME\.ssh\vagrant_unsecure_key" -t vagrant@$CentOSIP "sudo yum install net-tools -y"
docker-machine create --driver generic --generic-ip-address=$CentOSIP --generic-ssh-user vagrant --generic-ssh-key "$("$HOME\.ssh\vagrant_unsecure_key" -replace "\\","/")" $VMName
  • Install PowerShell Core on CentOS7Docker and allow for PSRemoting via SSH. Installing PSCore on CentOS7Docker will allow us to use PSRemoting in order to perform operations on CentOS7Docker using PowerShell (as opposed to bash) - which streamlines subsequent tasks significantly.
$InstallPowerShellScript = @'
curl https://packages.microsoft.com/config/rhel/7/prod.repo | sudo tee /etc/yum.repos.d/microsoft.repo
sudo yum install powershell -y
pscorepath=$(which pwsh)
subsystemline=$(echo \"Subsystem powershell $pscorepath -sshs -NoLogo -NoProfile\")
sudo sed -i \"s|sftp-server|sftp-server\n$subsystemline|\" /etc/ssh/sshd_config
sudo systemctl restart sshd
systemctl status docker | grep 'Active:'
'@
ssh -o "StrictHostKeyChecking=no" -o "IdentitiesOnly=yes" -i "$HOME\.ssh\vagrant_unsecure_key" -t vagrant@$CentOSIP "$InstallPowerShellScript"
  • Add the IPv4 address of CentOS7Docker to WinRM Trusted Hosts to allow for PSRemoting from localhost to CentOS7Docker. To do so, we can use the MiniLab Module’s Add-WinRMTrustedHost function.
Add-WinRMTrustedHost -NewRemoteHost $CentOSIP
  • Now we can create and use PSSessions to our CentOS7 VM!
$CentOS7LocalUser = "vagrant"
$CentOS7PSSession = New-PSSession -HostName $CentOSIP -KeyFilePath "$HOME\.ssh\vagrant_unsecure_key" -UserName $CentOS7LocalUser
  • OPTIONAL: If you’d like to ensure that Docker Storage on the CentOS7Socker follows best practices for Production use, create a .vhdx that will act as the dedicated block device for the Docker devicemappper storage driver
Stop-VM -VMName $VMName -Confirm:$false -Force -ea SilentlyContinue
while ($(Get-VM -Name $VMName).State -ne "Off") {
    Write-Host "Waiting for $VMName to turn off..."
    Start-Sleep -Seconds 2
}
$NewVhdFilePath = "$VMDestDir\$VMName\Virtual Hard Disks\devmapper.vhdx"
[uint64]$VhdSize = 30GB
$NewVhd = New-VHD -ComputerName localhost -Path $NewVhdFilePath -Dynamic -SizeBytes $VhdSize
Add-VMHardDiskDrive -VMName $VMName -Path $NewVhdFilePath
Start-VM -Name $VMName -Confirm:$false

# Give the CentOS7Docker a few seconds to boot
Start-Sleep -Seconds 15

# Remove the old PSSession session that was broken due to CentOS7Docker reboot
$CentOS7PSSession | Remove-PSSession

# Create a new PSSession to CentOS7Docker
$CentOS7PSSession = New-PSSession -HostName $CentOSIP -KeyFilePath "$HOME\.ssh\vagrant_unsecure_key" -UserName $CentOS7LocalUser

# Create the Docker daemon.json config
$DockerDaemonJson = @'
{
  "storage-opts": [
    "dm.directlvm_device=/dev/sdb",
    "dm.thinp_percent=95",
    "dm.thinp_metapercent=1",
    "dm.thinp_autoextend_threshold=80",
    "dm.thinp_autoextend_percent=20",
    "dm.directlvm_device_force=false"
  ]
}
'@
$EncodedPwshCmdPrep = @"
Set-Content -Path /etc/docker/daemon.json -Value @'`n$DockerDaemonJson`n'@
"@
$EncodedPwshCmd = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($EncodedPwshCmdPrep))

# Apply the Docker daemon.json config to CentOS7Docker
Invoke-Command -Session $CentOS7PSSession -ScriptBlock {
    sudo pwsh -EncodedCommand $using:EncodedPwshCmd
    $null = sudo systemctl restart docker *>/dev/null
    sleep 5
    systemctl status docker | grep 'Active:'
}

A Little More Prep

  • Let’s use Docker networking magic to make sure that in our current PSCore session, all calls to ‘docker’ are actually being run on CentOS7Docker
$Env:DOCKER_TLS_VERIFY = "1"
$Env:DOCKER_HOST = "tcp://$CentOSIP`:2376"
$Env:DOCKER_CERT_PATH = "$HOME\.docker\machine\machines\$VMName"
$Env:DOCKER_MACHINE_NAME = $VMName
$Env:COMPOSE_CONVERT_WINDOWS_PATHS = "true"
  • Confirm that calls to ‘docker’ are, in fact, being run on the CentOS7Docker (as opposed to our Docker For Windows installation)
PS C:\Users\pddomain> $(docker info) -match "Operating System"
Operating System: CentOS Linux 7 (Core)
  • OPTIONAL: Join CentOS7Docker to your Windows Active Directory Domain
$DomainAccount = "zero\zeroadmin"
$DomainCreds = [pscredential]::new($DomainAccount,$(Read-Host "Enter Password for $DomainAccount" -AsSecureString))

<#
NOTE: There is currently an issue creating securestring objects over an
SSH-based PSRemoting Session. See: https://github.com/PowerShell/PowerShell/issues/7239
As a workaround, you can use my EncryptDecrypt Module to create an Encrypted file that
contains the password for the Domain Account that you intend to use to join
CentOS7Docker to the Domain.

NOTE: The below 'Install-Module EncryptDecrypt' uses -AllowClobber because
it shares the 'New-SelfSignedCertificateEx' and 'Get-EncryptionCert' functions in
common with the MiniLab Module. These functions are exactly the same in both Modules.
#>

Install-EncryptDecrypt -AllowClobber
Import-Module EncryptDecrypt

$CNOfNewCert = "ZeroAdminPwd"
$NewEncryptedFileSplatParams = @{
    SourceType          = "String"
    ContentToEncrypt    = $($DomainCreds.GetNetworkCredential().Password)
    CNOfNewCert         = $CNOfNewCert
    FileToOutput        = "$HOME\$CNOfNewCert.txt"
}
$ZeroAdminEncryptedPwdInfo = New-EncryptedFile @NewEncryptedFileSplatParams
$FilesToCopyToCentOS7 = $ZeroAdminEncryptedPwdInfo.AllFileOutputs
foreach ($FilePath in $FilesToCopyToCentOS7) {
    Copy-Item -ToSession $CentOS7PSSession -Path $FilePath -Destination "/home/$CentOS7LocalUser/$($FilePath | Split-Path -Leaf)"
}
$RSAEncryptedFileItem = Get-Item -Path $($FilesToCopyToCentOS7 | Where-Object {$_ -match "\.rsaencrypted"})
$PFXFileItem = Get-Item -Path $($FilesToCopyToCentOS7 | Where-Object {$_ -match "\.pfx"})
$RSAEncryptedFileName = $RSAEncryptedFileItem.Name
$PFXFileName = $PFXFileItem.Name

[System.Collections.ArrayList]$FunctionsForRemoteUse = @(
    $(Get-Module EncryptDecrypt).Invoke({$FunctionsForSBUse})
    $(Get-Module MiniLab).Invoke({${Function:GetElevation}.Ast.Extent.Text})
    ${Function:Join-LinuxToAD}.Ast.Extent.Text
)

# Join the Domain
Invoke-Command -Session $CentOS7PSSession -ScriptBlock {
    # Load the EncryptDecrypt Module as well as the Join-LinuxToAD function from the MiniLab Module in the Remote Session
    $using:FunctionsForRemoteUse | foreach {Invoke-Expression $_}

    # Decrypt the Password within the .rsaencrypted file you copied to CentOS7Docker
    $ContentToDecrypt = $HOME + '/' + $using:RSAEncryptedFileName
    $PathToPfxFile = $HOME + '/' + $using:PFXFileName

    $DecryptedContentSplatParams = @{
        SourceType          = "File"
        ContentToDecrypt    = $ContentToDecrypt
        PathToPfxFile       = $PathToPfxFile
        NoFileOutput        = $True
    }
    $DecryptedContentInfo = Get-DecryptedContent @DecryptedContentSplatParams
    $PTPwd = $DecryptedContentInfo.DecryptedContent

    # The Join-LinuxToAD function requires elevated privileges, so we have to prep to use 'sudo pwsh',
    # which means our commands need to be string-ified
    [System.Collections.ArrayList]$FunctionsToLoadInSudoPwsh = @()
    foreach ($Func in $using:FunctionsForRemoteUse) {
        $FunctionName = $($($Func -split "`n")[0] -split '[\s]')[1]
        if ($FunctionName -match "GetElevation|Join-LinuxToAD") {
            $null = $FunctionsToLoadInSudoPwsh.Add($Func)
        }
    }

    $SudoPwshOperations = @(
        $FunctionsToLoadInSudoPwsh
        "`$PwdSecureString = ConvertTo-SecureString '$PTPwd' -AsPlainText -Force"
        "`$DomainCreds = [pscredential]::new('$using:DomainAccount',`$PwdSecureString)"
        "Join-LinuxToAD -DomainName '$using:DomainName' -DomainCreds `$DomainCreds"
    )
    $SudoPwshOperationsAsString = $SudoPwshOperations -join "`n"
    
    $Bytes = [System.Text.Encoding]::Unicode.GetBytes($SudoPwshOperationsAsString)
    $EncodedCommand = [Convert]::ToBase64String($Bytes)

    $JoinDomainResult = sudo pwsh -EncodedCommand $EncodedCommand

    if ($JoinDomainResult -match 'Success') {
        Remove-Item -Path $ContentToDecrypt -Force
        Remove-Item -Path $PathToPfxFile -Force
        "Success"
    }
    else {
        "Failure"
    }
}
  • Let’s update our DNS Records so that we can resolve CentOS7Docker.$DomainName. Assuming you’re using a Windows DNS Server and depending on your particular DNS implementation, you can use PowerShell to create a new A entry and corresponding reverse lookup.

NOTE: If you’re already logged into the localhost as a Domain Admin (or user with appropriate permissions to update DNS) on the same Domain as the DNS Server, you don’t need the below $DNSServerCreds or the -Credential parameter

$FQDNOfDNSServer = "ZeroDC01.$DomainName"
$DNSServerCreds = $DomainCreds
Invoke-Command -ComputerName $FQDNOfDNSServer -Credential $DNSServerCreds -ScriptBlock {
    $AddDNSServerRecordSplatParams = @{
        Name            = "CentOS7Docker"
        ZoneName        = $using:DomainName
        AllowUpdateAny  = $True
        IPv4Address     = $using:CentOSIP
        CreatePtr       = $True
    }
    Add-DnsServerResourceRecordA @AddDNSServerRecordSplatParams
}

Time To Create Some Linux Containers!

Now we’re finally ready to use Docker to establish our CFSSL Root and Subordinate/Intermediate/Issuing Certificate Authorities!

  • Let’s create a ‘docker volume’ called ‘cfsslconfig’ that will contain configuration files as well as all file outputs that our CFSSL Docker Containers generate.
# Make sure calls to 'docker' are still being executed on the CentOS7 VM (as opposed to our Windows localhost)
PS C:\Users\pddomain> $(docker info) -match "Operating System"
Operating System: CentOS Linux 7 (Core)

# Create the docker volume 'cfsslconfig' on the CentOS7Docker VM
docker volume create cfsslconfig
$CFSSLDockerVolJson = docker volume inspect cfsslconfig
$CFSSLDockerVolMountPoint = $($CFSSLDockerVolJson | ConvertFrom-Json).MountPoint
  • There are several configuration files that we need to write to our docker volume before we can use cfssl containers to create our Root and Issuing CAs.
# Set some variables that will be used in this and subsequent steps
$RootCAJsonConfigFilePath = "$CFSSLDockerVolMountPoint/root_config.json"
$RootCACSRJsonFilePath = "$CFSSLDockerVolMountPoint/csr_root_ca.json"
$IntermediateCAJsonConfigFilePath = "$CFSSLDockerVolMountPoint/intermediate_config.json"
$IntermediateCACSRJsonFilePath = "$CFSSLDockerVolMountPoint/csr_intermediate_ca.json"
$CompName = "MyCompany"
$Org = "DevOps"
$Locale = "Philadelphia"
$Province = "PA"
$Nation = "US"

# Create a ScriptBlock that will write the config files to the docker volume
$DockerAndCFSSLConfigTasks = {
    $SudoPwshOutputUser = $using:CentOS7LocalUser
    $SudoPwshOutputDir = $HOME
    $CFSSLDockerVolMount = $using:CFSSLDockerVolMountPoint
    $RootCAConfigPath = $using:RootCAJsonConfigFilePath
    $RootCACSRPath = $using:RootCACSRJsonFilePath
    $IntermediateCAConfigPath = $using:IntermediateCAJsonConfigFilePath
    $IntermediateCACSRPath = $using:IntermediateCACSRJsonFilePath
    $CompanyName = $using:CompName
    $OrgUnit = $using:Org
    $City = $using:Locale
    $State = $using:Province
    $Country = $using:Nation

    $RootCAConfigJson = @"
{
    "signing": {
        "default": {
            "expiry": "87600h"
        },
        "profiles": {
            "client": {
                "usages": [
                    "signing",
                    "key encipherment",
                    "client auth"
                ],
                "expiry": "87600h"
            },
            "server": {
                "usages": [
                    "signing",
                    "key encipherment",
                    "server auth",
                    "client auth"
                ],
                "expiry": "87600h"
            }
        }
    }
}
"@

    $CSRRootCAJson = @"
{
    "CN": "$CompanyName`RootCA",
    "key": {
        "algo": "rsa",
        "size": 4096
    },
    "names": [
        {
            "O": "$CompanyName",
            "OU": "$OrgUnit",
            "L": "$City",
            "ST": "$State",
            "C": "$Country"
        }
    ],
    "ca": {
        "expiry": "262800h"
    }
}
"@

    $IntermediateCAConfigJson = @"
{
    "signing": {
        "default": {
            "usages": [
                "signing",
                "cert sign",
                "crl sign"
            ],
            "expiry": "262800h",
            "ca_constraint": {
                "is_ca": true,
                "max_path_len":0,
                "max_path_len_zero": true
            }
        },
        "profiles": {
            "clientint": {
                "usages": [
                    "signing",
                    "key encipherment",
                    "client auth"
                ],
                "expiry": "87600h"
            },
            "serverint": {
                "usages": [
                    "signing",
                    "key encipherment",
                    "server auth",
                    "client auth"
                ],
                "expiry": "87600h"
            }
        }
    }
}
"@

    $CSRIntermediateCAJson = @"
{
    "CN": "$CompanyName`IntermediateCA",
    "key": {
        "algo": "rsa",
        "size": 4096
    },
    "names": [
        {
            "O": "$CompanyName",
            "OU": "$OrgUnit",
            "L": "$City",
            "ST": "$State",
            "C": "$Country"
        }
    ],
    "ca": {
        "expiry": "42720h"
    }
}
"@

    $SudoPwshOperations = @"
`$RootCAConfigJson = @'
$RootCAConfigJson
'@

`$CSRRootCAJson = @'
$CSRRootCAJson
'@

`$IntermediateCAConfigJson = @'
$IntermediateCAConfigJson
'@

`$CSRIntermediateCAJson = @'
$CSRIntermediateCAJson
'@

Set-Content -Path '$RootCAConfigPath' -Value `$RootCAConfigJson
Set-Content -Path '$RootCACSRPath' -Value `$CSRRootCAJson
Set-Content -Path '$IntermediateCAConfigPath' -Value `$IntermediateCAConfigJson
Set-Content -Path '$IntermediateCACSRPath' -Value `$CSRIntermediateCAJson

# Create Output
`$RootCAInfo = @{
    RootCAConfigFileItem    = Get-Item '$RootCAConfigPath'
    RootCAConfigContent     = Get-Content '$RootCAConfigPath'
    RootCACSRFileItem       = Get-Item '$RootCACSRPath'
    RootCACSRContent        = Get-Content '$RootCACSRPath'
}
`$IntermediateCAInfo = @{
    IntermediateCAConfigFileItem    = Get-Item '$IntermediateCAConfigPath'
    IntermediateCAConfigContent     = Get-Content '$IntermediateCAConfigPath'
    CSRIntermediateCAFileItem       = Get-Item '$IntermediateCACSRPath'
    CSRIntermediateCAContent        = Get-Content '$IntermediateCACSRPath'
}
`$Output = [pscustomobject]@{
    RootCAInfo              = `$RootCAInfo
    IntermediateCAInfo      = `$IntermediateCAInfo
}
`$Output | Export-CliXml '$SudoPwshOutputDir/DoDockerAndCFSSLConfigsResult.xml'
chown $SudoPwshOutputUser`:$SudoPwshOutputUser '$SudoPwshOutputDir/DoDockerAndCFSSLConfigsResult.xml'
chmod 755 '$SudoPwshOutputDir/DoDockerAndCFSSLConfigsResult.xml'
"@

    $Bytes = [System.Text.Encoding]::Unicode.GetBytes($SudoPwshOperations)
    $EncodedCommand = [Convert]::ToBase64String($Bytes)

    $DoDockerAndCFSSLConfigs = sudo pwsh -EncodedCommand $EncodedCommand
    $DoDockerAndCFSSLConfigsResult = Import-CliXml "$HOME/DoDockerAndCFSSLConfigsResult.xml"
    Remove-Item "$HOME/DoDockerAndCFSSLConfigsResult.xml" -Force -ErrorAction SilentlyContinue
    $DoDockerAndCFSSLConfigsResult
}

$DockerAndCFSSLInitialConfig = Invoke-Command -Session $CentOS7PSSession -ScriptBlock $DockerAndCFSSLConfigTasks -HideComputerName
  • Now that our Root CA and Issuing/Intermediate CA Configuration files are in place on our docker volume, we are ready to create our Root and Subordinate CA Certificates!

IMPORTANT NOTE: The below docker commands create containers, generate file output, and destroy containers all at once. This means that the CFSSL Docker Containers only exist long enough to generate all certificate/key file outputs.

# Create the Root CA Certs/Keys
docker run --rm -i --mount source=cfsslconfig,target=/etc/cfssl --workdir="/etc/cfssl" --entrypoint /bin/bash cfssl/cfssl -c 'cfssl genkey -initca csr_root_ca.json | cfssljson -bare ca'

# Create the Intermediate CA Certs/Keys
docker run --rm -i --mount source=cfsslconfig,target=/etc/cfssl --workdir="/etc/cfssl" --entrypoint /bin/bash cfssl/cfssl -c 'cfssl gencert -initca csr_intermediate_ca.json | cfssljson -bare intermediate_ca'

# Have the Intermediate CA certificate signed by the Root CA
docker run --rm -i --mount source=cfsslconfig,target=/etc/cfssl --workdir="/etc/cfssl" --entrypoint /bin/bash cfssl/cfssl -c 'cfssl sign -ca ca.pem -ca-key ca-key.pem -config intermediate_config.json intermediate_ca.csr | cfssljson -bare intermediate_ca'

# After performing the above three operations, you should have the following files available under $CFSSLDockerVolMountPoint
<#
ca.csr                  csr_intermediate_ca.json        intermediate_config.json
ca-key.pem              intermediate_ca.csr             root_config.json
ca.pem                  intermediate_ca.pem
csr_root_ca.json        intermediate_ca-key.pem
#>
  • Now we need to copy the Root CA Public Cert and Issuing/Intermediate CA Public Cert to our localhost
$RootCAPublicCert = Invoke-Command -Session $CentOS7PSSession -ScriptBlock {
    $CommandString = "Get-Content $using:CFSSLDockerVolMountPoint/ca.pem"
    sudo pwsh -Command $CommandString
}
$RootCAPemOutputPath = "$HOME\Downloads\ca.pem"
$RootCAPublicCert | Set-Content $RootCAPemOutputPath
# Create a .cer file for convenience in case you want to double-click on it in File Explorer
Copy-Item -Path $RootCAPemOutputPath -Destination $($RootCAPemOutputPath -replace "\.pem",".cer")

$SubCAPublicCert = Invoke-Command -Session $CentOS7PSSession -ScriptBlock {
    $CommandString = "Get-Content $using:CFSSLDockerVolMountPoint/intermediate_ca.pem"
    sudo pwsh -Command $CommandString
}
$SubCAPemOutputPath = "$HOME\Downloads\intermediate_ca.pem"
$SubCAPublicCert | Set-Content $SubCAPemOutputPath
# Create a .cer file for convenience in case you want to double-click on it in File Explorer
Copy-Item -Path $SubCAPemOutputPath -Destination $($SubCAPemOutputPath -replace "\.pem",".cer")
  • Currently, your local Windows machine does NOT trust the new Root CA and Intermediate/Issuing CA created by the CFSSL Containers. So, let’s install the Root CA and Issuing/Intermediate CA Public Certificates on our Windows machine. This will also make our localhost Windows machine trust any certificates signed by the CFSSL Intermediate/Issuing CA.
Import-Certificate -FilePath $RootCAPemOutputPath -CertStoreLocation Cert:\LocalMachine\CA
Import-Certificate -FilePath $RootCAPemOutputPath -CertStoreLocation Cert:\LocalMachine\Root
Import-Certificate -FilePath $SubCAPemOutputPath -CertStoreLocation Cert:\LocalMachine\CA

Issuing New Certificates Using the CFSSL Issuing/Intermediate CA

CFSSL has a couple of interesting features that you may want to use - including a ‘Scan’ feature that validates a website’s TLS configuration, and an HTTP API feature that exposes 9 different HTTP API endpoints for requesting/signing/issuing certificates (more information on the HTTP API here: https://github.com/cloudflare/cfssl/tree/master/doc/api. Of course, the CFSSL webserver needs a TLS Certificate, so let’s take this opportunity to see how we can use Docker to request/receive new certificates.

$CNOfTLSCert = "CFSSLHttpApi"
$CNOfTLSCertLowerCase = $CNOfTLSCert.ToLowerInvariant()
$CFSSLTLSCertCSRPath = "$CFSSLDockerVolMountPoint/$CNOfTLSCertLowerCase`_csr.json"

# Create a ScriptBlock that will write the CSR config for our new TLS Cert to our docker volume
$GenTLSCertForCFSSLHttpApi = {
    $SudoPwshOutputUser = $using:CentOS7LocalUser
    $SudoPwshOutputDir = $HOME
    $TLSCertCN = $using:CNOfTLSCert
    $CFSSLTLSCertCSRPath = $using:CFSSLTLSCertCSRPath
    $DomainName = $using:DomainName
    $CertIP = $using:CentOSIP
    $CompanyName = $using:CompName
    $OrgUnit = $using:Org
    $City = $using:Locale
    $State = $using:Province
    $Country = $using:Nation

    # IMPORTANT NOTE: In the below CSR, we need to add "0.0.0.0" because of Docker networking
    $CentOS7DockerTLSCSR = @"
{
    "CN": "$TLSCertCN",
    "hosts": [
        "CentOS7Docker.$DomainName",
        "$CertIP",
        "0.0.0.0"
    ],
    "key": {
        "algo": "rsa",
        "size": 2048
    },
    "names": [
        {
            "O": "$CompanyName",
            "OU": "$OrgUnit",
            "L": "$City",
            "ST": "$State",
            "C": "$Country"
        }
    ],
    "profile": "serverint"
}    
"@

    $SudoPwshOperations = @"
`$CentOS7DockerTLSCSR = @'
$CentOS7DockerTLSCSR
'@

Set-Content -Path '$CFSSLTLSCertCSRPath' -Value `$CentOS7DockerTLSCSR

# Create Output
`$Output = @{
    TLSCertCSRConfigFileItem         = Get-Item '$CFSSLTLSCertCSRPath'
    TLSCertCSRConfigContent          = Get-Content '$CFSSLTLSCertCSRPath'
}
`$Output | Export-CliXml '$SudoPwshOutputDir/CFSSLTLSCertCSRInfo.xml'
chown $SudoPwshOutputUser`:$SudoPwshOutputUser '$SudoPwshOutputDir/CFSSLTLSCertCSRInfo.xml'
chmod 755 '$SudoPwshOutputDir/CFSSLTLSCertCSRInfo.xml'
"@

    $Bytes = [System.Text.Encoding]::Unicode.GetBytes($SudoPwshOperations)
    $EncodedCommand = [Convert]::ToBase64String($Bytes)

    $WriteCFSSLTLSCSRConfig = sudo pwsh -EncodedCommand $EncodedCommand
    $CFSSLTLSCertConfigInfo = Import-CliXml "$HOME/CFSSLTLSCertCSRInfo.xml"
    Remove-Item "$HOME/CFSSLTLSCertCSRInfo.xml" -Force -ErrorAction SilentlyContinue
    $CFSSLTLSCertConfigInfo
}

$WriteCFSSLCSRConfig = Invoke-Command -Session $CentOS7PSSession -ScriptBlock $GenTLSCertForCFSSLHttpApi -HideComputerName
  • Now that we’ve written the TLS Cert CSR config to our docker volume, let’s actually create the CFSSL WebServer TLS Cert (and associated files)
docker run --rm -i --mount source=cfsslconfig,target=/etc/cfssl --workdir="/etc/cfssl" --entrypoint /bin/bash cfssl/cfssl -c 'cfssl gencert -ca intermediate_ca.pem -ca-key intermediate_ca-key.pem -config intermediate_config.json -profile serverint cfsslhttpapi_csr.json | cfssljson -bare cfsslhttpapi'

# Now you should have the following files available under $CFSSLDockerVolMountPoint on CentOS7Docker
<#
ca.csr                 cfsslhttpapi-key.pem      intermediate_ca-key.pem
ca-key.pem             cfsslhttpapi.pem          intermediate_ca.pem
ca.pem                 csr_intermediate_ca.json  intermediate_config.json
cfsslhttpapi.csr       csr_root_ca.json          root_config.json
cfsslhttpapi_csr.json  intermediate_ca.csr
#>
  • Now that we have cfsslhttpapi-key.pem and cfsslhttpapi.pem under $CFSSLDockerVolMountPoint on CentOS7Docker, we can start the CFSSL WebServer that can use the Issuing Cert Authority to handle future certificate requests via HTTP API
docker run -d --mount source=cfsslconfig,target=/etc/cfssl --workdir="/etc/cfssl" -p 8888:8888 --name=cfssl-jump cfssl/cfssl serve -address 0.0.0.0 -port 8888 -config intermediate_config.json -ca-key intermediate_ca-key.pem -ca intermediate_ca.pem -tls-cert cfsslhttpapi.pem -tls-key cfsslhttpapi-key.pem
  • Assuming you updated your DNS records in an earlier step, you can now navigate to https://centos7docker.$DomainName:8888. And since you installed Public Certificates for your new Root CA and Intermediate/Issuing CA on your local Windows machine, your browser should trust the site certificate.

Conclusion

I wanted to write more about how to make certificate requests using the CFSSL HTTP API endpoints via curl or PowerShell’s Invoke-RestMethod cmdlet, but there is an opensource product out there that is a lot more mature when it comes to handling Certificate Requests via REST - Hashicorp’s Vault Server. My next post will (should) be about setting up a Vault Server using PowerShell Core and Docker For Windows and using it to handle Certificate Requests via HTTP API.