Authentication/deployment method for desktop agent with general users

The docs describe a number of concepts and configuration methods quite well, and through manual interaction with the desktop app I can see a Poweruser with a work instruction being able to use it, but is there a typical deployment method for general end users/a business suitable deployment?

The requirement would be to wrap up the desktop agent (ZDEW) with an installer script and be able to automaticaly ddeploy to users via an MDM, and have the agent working/services accessible once a user hits their desktop.

The Third Party CA method looks promising - issue a x509 device or user auth cert through enterprise PKI to managed devices and have clients self registe - since this is similar to how many VPNs are deployed and scales well.
However the docs for the windows agent only show GUI methods for importing the cert (no guidance on how to automaticaly do so from an install script) - and it appears the agent can't use windows cert store/ certs anyway - which prevents accessing most enterprise/MDM deployed certs?

SSO using OIDC is the other option presented in the docs, however it seems the URL also needs to be manualy set through the GUI, and also that signing in is a manual task for the user (not seeing an option to silently/automaticaly log the user in using i.e their Entra PRT or similar).

Is there an intended architecture/method in OpenZiti to have an agent deployable and 'ready to go' from an MDM for wide spread use, or any docs reqgarding pre-configuring the agent at all (registry keys, command references or an ADMX?) Or is the focus on the project more targeted at supporting Developers/Power users only?

Hi @Bailey-Coole, welcome to the community and to OpenZiti!

Thanks for that feedback. I don't think any "typical end users" (non-power-user) have had any issues with using any of the clients to date. We use OpenZiti internally of course and have HR/Sales/Finance/Help Desk all using it quite successfully and not all of them would be considered "power users". NetFoundry the company also has customers with thousands of individual clients and I'm sure not all of them are power users. However, I think I get your point. You're looking to make it even easier maybe even to the point of "no touch".

There are quite a few options to do the steps from a script. I'll describe a couple and you can follow up if you like... All OpenZiti identities are described in a json file. The file needs to have certain entries. The format of the file is pretty simple but I don't believe we have it documented anywhere... You could easily craft that file and put it into the location where identities are stored, currently the system profile (but will likely move to PROGRAMDATA at some point).

The file format looks like:

{
  "ztAPI": "https://ec2-3-18-113-172.us-east-2.compute.amazonaws.com:8441",
  "ztAPIs": [
    "https://ec2-3-18-113-172.us-east-2.compute.amazonaws.com:8441"
  ],
  "id": {
    "cert": "-----BEGIN CERTIFICATE-----\nMIIDmTCCAYGgAwIBAgIDB2FIMA0GCSqGSIb3DQEBCwUAMHgxCzAJBgNVBA
    "key": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgnTo/cQ+/P5+
    "ca": "-----BEGIN CERTIFICATE-----\nMIIF5DCCA8ygAwIBAgIRAIN5uqTEgZ5nxKlrVRivaCMwDQYJKoZIhvcNAQEL
  }
}

The identity file can contain URI references (file://) such as:

  "id": {
    "key": "file:///Windows/System32/config/systemprofile/AppData/Roaming/NetFoundry/mm.key",
    "cert": "file:///Windows/System32/config/systemprofile/AppData/Roaming/NetFoundry/mm.cert",
    "ca": "file:///Windows/System32/config/systemprofile/AppData/Roaming/NetFoundry/mm.ca"
  },

If you put a file into C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry that references a key/cert and has the proper CA field (which you get from the controller), then you can manually generate these identity files if you wish.

If you have a .jwt for the user, it is pretty easy to use the ziti-edge-tunnel binary and 'add' the identity via command line:

& 'C:\Program Files (x86)\NetFoundry Inc\Ziti Desktop Edge\ziti-edge-tunnel.exe' add --jwt c:\temp\new-id.jwt -i new-id.json

You can also add an identity by url this way and not require the jwt but as you noted, external auth does require the user to initiate and complete the OIDC flow:

& 'C:\Program Files (x86)\NetFoundry Inc\Ziti Desktop Edge\ziti-edge-tunnel.exe' add --url https://ctrl.zrok.clint.demo.openziti.org:8441 -i id-by-url

I have no exeprience with MDM myself and I've never tried to deploy a client using it. I do think it's pretty easy to script the deployment of an identity, but it might take a back-and-forth of you asking us questions to get you to success, if you don't mind having the dialogue I'm sure we can find some solution (if the stuff I wrote above doesn't already get you there)

Let me know if that helps and if you have follow-up questions, I'll try my best to help out

On top of what @TheLumberjack says, I would only add that using MDM for deployment/automation is something I know several NetFoundry customers have done, much of it done with the help of @NicFragale. This is also helped with some of the automation we have built for working with external CAs - https://support.netfoundry.io/hc/en-us/articles/360048210572-How-to-Register-Endpoints-with-Certificates-from-Another-Authority.

Ok, this is promising - so it looks like the functionality exists but, but the documentation hasn't caught up yet.

The cert method does looks promising if it can be configured using config files instead of just the GUI, but it might take some tinkering to figure out if a private key can be exfiltrated from the cert store and inserted into the config.

I think 'no touch' is the better way to describe what we are going for, due to the combination of remotely deploying devices with Windows Autopilot, and needing essentially 'always on' network connectivity to internal services - We have had luck with MS AOVPN and Palo GP for this since they can be fully deployed from MDM/Intune. We had trailed some SASE clients that did need to be explicitly authed, but even with SSO we found this caused problems (user's would not see or would close out the sign in prompt, or the prompt triggered during device provisioning phase so wasn't ever seen by user etc).

Also, is syncing Identities from Entra part of Ziti, or netFoundry only?

There's an issue for supporting functionality like this without exporting. It's not quite as simple and straightforward as it sounds for everyone, just because OpenZiti tunnelers, by their nature (taking IP-based underlay traffic and sending over the overlay), need to operate at a system-wide level. I am pretty sure one can mark keys as exportable when importing them manually, I don't know if the flows you have access to would allow for it but do let us know what you discover? :slight_smile:

Sync'ing identities from Entra is a NetFoundry Enterprise-style add-on. It's not the sort of thing that OpenZiti is looking to solve really, I'd be surprised if the OpenZiti project would deliver such a thing. That's more automation around OpenZiti.

Yes will do, I'm going to go tinker for a bit with the agent. I also found another thread here of what seems someone else hitting the same issues. I think I have a plan based on what's been shared so far, but need to build more of a test network deployment first.

Ah. Would a typical Ziti implementation be expected to rely solely on manual identity/access management within the app (or 'home-grown' automation). Or is there more of a soft expectation there that business customers should use the paid product if wanting to centralise IAM? I don't think this is too critical to our use case, just trying to understand the design intent/typical deployment.

I think the only honest answer really is, "it depends". I think OpenZiti is solving the zero trust overlay problem. How that overlay is provisioned is entirely up to each individual instance, so there's no one 'right way'. It has APIs so that people can integrate with it and automate these sorts of provisioning, but OpenZiti itself hasn't tried to be that (at least not yet). The idea is to allow people the flexibility to implement what they want/need.

There are plenty of business customers using the open source project already. That thread you found is one of them. I would say it's more of a "if you want to do all this sort of thing on your own, have at it" type of attitude, and if not, well then, maybe NetFoundry has a solution for you. OpenZiti's license is very open, it's fully programmable, it's open source, but at the end of the day there needs to be value that NetFoundry can provide above and beyond the OpenZiti project itself as well.

So it'd be great if everyone used an OpenZiti overlay provided by NetFoundry, (there are numerous benefits to a business after all, to allowing the inventor/subject matter experts run the overlay) but we don't make these types of decisions to actively 'paywall' features. If it belongs in OpenZiti, we add it to OpenZiti based on the sort of ethos I laid out above.

hth

1 Like

So after having a bit of a play around and cobbling this together using code from a few other projects (mostly the CloudLAPS project and a few python crypto library examples), we have a proof of concept certificate signing and installation system for Ziti identity certs.

It uses an Azure function (should be cheap to run) and a private RSA key in Azure key vault to authenticate devices based on their Entra ID enrollment certificate and then issues a user and device certificate. The certs issued have no user auth EKUs (and the Ziti PKI doesn't/doesn't need to chain to our corp PKI anyway) so not really worried about it being used for privileged escalation attacks (but they code probably needs a proper audit and review before being put into prod regardless).

I'm not sure if we will be continuing with the separate device and user tunnel identities approach into prod, but it is simple enough to remove the extra operations.

One gotcha we did find was that we couldn't enroll 2 identities with the same CN, so we may also need to code to handle i.e a user changing a device.

import json
import os
import asyncio
import re
import base64
import hashlib
import base64
import typing
import datetime
import logging
import hashlib
from datetime import timedelta
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives._asymmetric import AsymmetricPadding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives import _serialization
from cryptography.hazmat.primitives.asymmetric import utils as asym_utils
import azure.functions as func
from azure.identity import DefaultAzureCredential
from azure.identity import DefaultAzureCredential
from azure.keyvault.keys import KeyClient
from azure.keyvault.keys.crypto import CryptographyClient, EncryptionAlgorithm
from azure.keyvault.keys.crypto import SignatureAlgorithm
from msgraph import GraphServiceClient


#function definitions

#signs a CSR
def sign_certificate_request(csr_cert, ca_cert, private_ca_key, subjectOverride = None):
    subject = csr_cert.subject
    if bool(subjectOverride):
        subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subjectOverride)])
    cert = x509.CertificateBuilder().subject_name(
        subject
    ).issuer_name(
        ca_cert.subject
    ).public_key(
        csr_cert.public_key()
    ).serial_number(
        x509.random_serial_number()
    ).not_valid_before(
        datetime.datetime.now(datetime.timezone.utc)
    ).not_valid_after(
        # Our certificate will be valid for 10 days for testing
        datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=10)
    # Sign our certificate with our private key
    ).sign(private_ca_key, hashes.SHA256())

    # return DER certificate
    return cert

#async funciton to handle graph queries, as required by Azure Python SDK
async def get_deviceData(deviceId, client):
    asyncGraphResponses = {
        "display_name": None,
        "ownership": None,
        "enabled": None,
        "EntraAttributeThumbprint":  None,
        "EntraAttributePublicKeyHash":  None,
        "device_owner": None
    }
    try:
        device = await client.devices_with_device_id(deviceId).get()
        deviceOwners = await client.devices.by_device_id(device.id).registered_owners.get()
    except:
        #we don't throw an exception here, we let the main thread handle it
        logging.warning("No response from Graph API")
        return asyncGraphResponses

    if device:
        asyncGraphResponses["display_name"] = device.display_name
        asyncGraphResponses["ownership"] = device.device_ownership
        asyncGraphResponses["enabled"] = device.account_enabled
        decodedKey = (base64.b64decode(re.search("key=b'(.+?)'", str(device.alternative_security_ids)).group(1)).decode("utf-8")).replace("\x00", "") #wtf
        asyncGraphResponses["EntraAttributeThumbprint"]     = str(decodedKey)[21:61]
        asyncGraphResponses["EntraAttributePublicKeyHash"]  = str(decodedKey)[61:]
        asyncGraphResponses["device_owner"] = re.search("user_principal_name='(.+?)'", str(deviceOwners)).group(1) #only takes 1st owner
         
        logging.debug("Device Name from Entra is " + asyncGraphResponses["display_name"])
        logging.debug("Device ownership from Entra is " + asyncGraphResponses["ownership"])
        logging.debug("Device enabled status from Entra is " + str(asyncGraphResponses["enabled"]))
        logging.debug("Entra certificate thumbprint for this Device is " + asyncGraphResponses["EntraAttributeThumbprint"])
        logging.debug("Entra certificate Public Key Hash for this Device is " +  asyncGraphResponses["EntraAttributePublicKeyHash"])
        logging.debug("Device owner from Entra is " + asyncGraphResponses["device_owner"])

    return asyncGraphResponses

#check if device is the entra device it is claiming to be, and is elligible to recieve certs
def authenticateDevice(graph, device):
    if  not bool((graph["ownership"]).casefold() == 'Company'.casefold()):
        return "Device not Company owned"

    if  not bool(graph["enabled"]):
        return "Device Enabled"

    if  not bool((graph["EntraAttributeThumbprint"]).casefold() == (device["deviceCertThumbprint"]).casefold()):
        return "Device Certificate Thumbprint does not match Entra"

    if  not bool((graph["EntraAttributePublicKeyHash"]).casefold() == (device["deviceCertPublicKeyHash"]).casefold()):
        return "Device Certificate Public Key Hash does not match Entra"

    if  not bool((graph["display_name"]).casefold() == (device["display_name"]).casefold()):
        return "Device name does not match Entra"
    
    return None

#custom key class defintion, based on oracle cloud sample code
class AzureVaultPrivateKey(rsa.RSAPrivateKey):
    def __init__(self, KVUri, key_name, credential):
        self._key_id = key_name
        key_client = KeyClient(vault_url=KVUri, credential=credential)
        Azurekey = key_client.get_key(key_name)
        self.crypto_client = CryptographyClient(Azurekey, credential=credential)

    def sign(
        self,
        data: bytes,
        padding: AsymmetricPadding,
        algorithm: typing.Union[asym_utils.Prehashed, hashes.HashAlgorithm],
    ) -> bytes:
        assert not isinstance(algorithm, asym_utils.Prehashed)
        assert isinstance(padding, PKCS1v15)
        if isinstance(algorithm, hashes.SHA256):
            alg = SignatureAlgorithm.rs256
            digest = hashlib.sha256(data).digest()
        elif isinstance(algorithm, hashes.SHA384):
            alg = SignatureAlgorithm.rs384
            digest = hashlib.sha384(data).digest()
        elif isinstance(algorithm, hashes.SHA512):
            alg = SignatureAlgorithm.rs512
            digest = hashlib.sha512(data).digest()
            
        result = self.crypto_client.sign(alg, digest)

        # TODO: handle errors.
        return result.signature

    # Every method below here is unimplemented for now but needs to be
    # present to satisfy the interface.
    def _key_info(self):
        raise NotImplementedError()

    def decrypt(self, ciphertext: bytes, padding: AsymmetricPadding) -> bytes:
        raise NotImplementedError()

    def key_size(self) -> int:
        raise NotImplementedError()

    def public_key(self) -> "rsa.RSAPublicKey":
        raise NotImplementedError()

    def private_numbers(self) -> "rsa.RSAPrivateNumbers":
        raise NotImplementedError()

    def private_bytes(
        self,
        encoding: _serialization.Encoding,
        format: _serialization.PrivateFormat,
        encryption_algorithm: _serialization.KeySerializationEncryption,
    ) -> bytes:
        raise NotImplementedError()


app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)

@app.route(route="signClientCSR")
def signClientCSR(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')
    
    logging.info("Loading Input Data")
    data = req.get_json()

    #Basic input validation
    inputValuesNames = ["DeviceName", "DeviceEntraCert", "UserCSR", "DeviceCSR", "UserCSRSig", "DeviceCSRSig"]

    logging.info("Validating Input")
    try:
        for inputValuesName in inputValuesNames:
            if  not bool(data.get(inputValuesName)):
                return func.HttpResponse(
                    "Request missing required value: " + inputValuesName,
                    status_code=400
                )
                raise Exception(inputValuesName + " not present")
    except:
        logging.critical("Input not correct")
        return func.HttpResponse(
            "Error reading Input: Input not Correct",
            status_code=400
        )

    logging.info("All Inputs Prsent")
    #all values must atleast exist at this point, but may not be valid
    logging.debug(data.get("DeviceName"))
    logging.debug(data.get("DeviceEntraCert"))
    logging.debug(data.get("UserCSR"))
    logging.debug(data.get("DeviceCSR"))
    logging.debug(data.get("UserCSRSig"))
    logging.debug(data.get("DeviceCSRSig"))

    #Dictionary to hold device data from the request
    deviceClaims = {
        "display_name": None,
        "deviceId":  None,
        "deviceCertPublicKeyPEM":  None,
        "deviceCertPublicKeyHash":  None,
        "deviceCertThumbprint": None
    }
    logging.info("Processing Input Data")
    #tries to load in data from the request, including cert de-serialisation and attribute exports
    try:
        deviceClaims["display_name"] = data["DeviceName"]
        presentedDeviceCert = x509.load_pem_x509_certificate(bytes(data["DeviceEntraCert"], "ascii"))
        deviceClaims["deviceId"] = presentedDeviceCert.subject.rfc4514_string().replace('CN=', '')
        deviceClaims["deviceCertPublicKeyPEM"] = str(presentedDeviceCert.public_key().public_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PublicFormat.PKCS1
        ))
        deviceCertPublicKeyIntermediary = str(deviceClaims["deviceCertPublicKeyPEM"]).replace(
        r"b'-----BEGIN RSA PUBLIC KEY-----", ''
        ).replace(r'\n', ''
        ).replace(r"-----END RSA PUBLIC KEY-----'", '')
        deviceClaims["deviceCertPublicKeyHash"] =  str(base64.b64encode(hashlib.sha256(base64.b64decode(deviceCertPublicKeyIntermediary)).digest())).replace("b'", '').replace("'", '')
        deviceClaims["deviceCertThumbprint"] = bytearray(presentedDeviceCert.fingerprint(hashes.SHA1())).hex()

        logging.debug("Device name is " + deviceClaims["display_name"] )
        logging.debug("Device certificate Device Id is " + deviceClaims["deviceId"] )
        logging.debug("Device certificate Public Key is " +  deviceClaims["deviceCertPublicKeyPEM"])
        logging.debug("Device certificate Public Key Hash is " +  deviceClaims["deviceCertPublicKeyHash"])
        logging.debug("Device certificate thumbprint is " + deviceClaims["deviceCertThumbprint"]) 
    except: 
        logging.critical("Device cert not Valid X509 certificate, or PyCA decided to deprecate required functionaity")
        return func.HttpResponse(
            "Device certificate presented is not a Valid X509 certificate",
            status_code=400
        )

    logging.info("Input Data Processed")
    #tries to connect to graph API as the current user/managed identity

    logging.info("Trying to authenticate to Graph API")
    try:
        credential = DefaultAzureCredential() #this is also reused for keyvault later

        scopes = ['https://graph.microsoft.com/.default']
        client = GraphServiceClient(credentials=credential, scopes=scopes)
    except:
        logging.critical("Unable to Authenticate to Graph API")
        return func.HttpResponse(
        "Server Unable to Auth to Graph API.",
        status_code=500
        )

    #Pulls in Data from Entra/Graph API (assuming device ID is valid
    logging.info("Connecting to Graph to Query for Device Attribtues")
    graphResponses = asyncio.run(get_deviceData(deviceClaims["deviceId"], client))

    print("Checking Graph returned expected values")
    #validates that we recieved good responses back from the async worker.
    graphResponsesValuesNames = ["display_name", "ownership", "enabled", "EntraAttributeThumbprint", "EntraAttributePublicKeyHash", "device_owner"]

    #Check all values exist
    for graphResponsesValuesName in graphResponsesValuesNames:

        if  not bool(graphResponses[graphResponsesValuesName]):
            logging.critical(graphResponsesValuesName + " not found")
            return func.HttpResponse(
            "Graph API failed to return value: " + graphResponsesValuesName,
            status_code=500
            )
          
    logging.info("All graph Values Present")

    logging.info("Trying to Authenticate Device")
    deviceAuthenticationError = authenticateDevice(graphResponses, deviceClaims)
    if  bool(deviceAuthenticationError):
        logging.critical("Input not correct")("Device authentication error" + deviceAuthenticationError)
        return func.HttpResponse(
        "Device authentication error" + deviceAuthenticationError,
        status_code=401 
        )

    #device is now authenticated
    logging.info("Device is likely the device it is claiming to be")

    logging.info("Checking that CSR Signatures have been signed by the authenticated device")
    try:
        presentedDeviceCert.public_key().verify(base64.b64decode(data["UserCSRSig"]), str(data["UserCSR"]).encode('utf-8'), padding.PKCS1v15(),hashes.SHA256())
        presentedDeviceCert.public_key().verify(base64.b64decode(data["DeviceCSRSig"]), str(data["DeviceCSR"]).encode('utf-8'), padding.PKCS1v15(),hashes.SHA256())
    except:
        logging.critical("Input not correct")("Certificate CSRs could not be validated")
        return func.HttpResponse(
        "CSR Signatures don't appear to be signed by the authenticated device certificate",
        status_code=401
        )

    logging.info("Certificate CSRs signed by the authenticated device")

    logging.info("Trying to authenticate to keyVault")
    KVUri = os.environ["KVURI"]
    key_name = os.environ["KEY_NAME"]
    try:
        AzureCAPrivateKey = AzureVaultPrivateKey(KVUri, key_name, credential)
    except:
        logging.critical("Could not authenticate to" + KVUri + " and access " + key_name )
        return func.HttpResponse(
        "Could not authenticate to key vault",
        status_code=500
        )
    
    logging.info("Got Access to Key")

    #get CA cert
    CA_CRT_PEM = os.environ["CA_CRT"]
    CA_crt = x509.load_pem_x509_certificate(CA_CRT_PEM.encode('utf-8'))

    logging.info("Signing Certs")
    userCRT = sign_certificate_request(x509.load_pem_x509_csr(str(data["UserCSR"]).encode('utf-8')), CA_crt, AzureCAPrivateKey, subjectOverride = graphResponses["device_owner"])
    deviceCRT = sign_certificate_request(x509.load_pem_x509_csr(str(data["DeviceCSR"]).encode('utf-8')), CA_crt, AzureCAPrivateKey, subjectOverride = graphResponses["display_name"])
    logging.info("Certs Signed")

    returnData = {
        "userCert": str(userCRT.public_bytes(serialization.Encoding.PEM)).replace("b'", '').replace("'", '').replace(r"\n", '\n'),
        "deviceCert":  str(deviceCRT.public_bytes(serialization.Encoding.PEM)).replace("b'", '').replace("'", '').replace(r"\n", '\n'),
    }

    logging.debug("User Cert:")
    logging.debug(returnData["userCert"])
    logging.debug("Device Cert:")
    logging.debug(returnData["deviceCert"])
    
    logging.info("Exiting Succesful!")
    return func.HttpResponse(json.dumps(returnData), status_code=201, mimetype="application/json")
<#
.SYNOPSIS
    Installer Script for OpenZiti Client that generates local private keys and install identities. .

.DESCRIPTION
    This script is based off the CloudLAPS methods for authenticating an Entra Joined device to an Azure function
    
    An azure function is used to produce and sign CSRs for the publioc keys. 

.NOTES
    FileName:    Install.ps1

#>


#Generates public and private keys. Private keys are written to the application's required locations, and the CSR containing the public keys are added to the request object.
function generate_keys {
    param (
        $data
    )

    #notes, will need to be updated to point to machine cert store when getting ready to deploy

    #Generates Private keys within cert store and prepares CSR. 
    #certreq.exe is used as a wrapper due to difficulties using rsa keys or x509 certs directly within powershell and .net
    certreq.exe -new .\CSR-Device.inf CSR-Device.req
    certreq.exe -new .\CSR-User.inf CSR-User.req

    #add csr data to request object
    $data["DeviceCSR"] = (Get-Content -Path CSR-Device.req -raw).psobject.BaseObject
    $data["UserCSR"]   = (Get-Content -Path CSR-User.req -raw).psobject.BaseObject

    #Extract Key and convert to PEM
    $deviceCertificate = Get-ChildItem -Path "Cert:\LocalMachine\REQUEST" -Recurse | Where-Object { $PSItem.Subject -eq 'CN=DEVICE' }
    $userCertificate = Get-ChildItem -Path "Cert:\LocalMachine\REQUEST" -Recurse | Where-Object { $PSItem.Subject -eq 'CN=USER' }

    $devicePrivateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($deviceCertificate)
    $userPrivateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($userCertificate)

    $devicePrivateKeyBytes = $devicePrivateKey.Key.Export([System.Security.Cryptography.CngKeyBlobFormat]::Pkcs8PrivateBlob)
    $userPrivateKeyBytes = $userPrivateKey.Key.Export([System.Security.Cryptography.CngKeyBlobFormat]::Pkcs8PrivateBlob)

    $devicePrivateKeyBase64 = [System.Convert]::ToBase64String($devicePrivateKeyBytes, [System.Base64FormattingOptions]::InsertLineBreaks)
    $userPrivateKeyBase64 = [System.Convert]::ToBase64String($userPrivateKeyBytes, [System.Base64FormattingOptions]::InsertLineBreaks)

    $deviceKeyPem = @"
-----BEGIN PRIVATE KEY-----
$devicePrivateKeyBase64
-----END PRIVATE KEY-----
"@

    $userKeyPem = @"
-----BEGIN PRIVATE KEY-----
$userPrivateKeyBase64
-----END PRIVATE KEY-----
"@


#should write to C:\Windows\System32\Config\systemprofile\AppData\Roaming\NetFoundry
Out-File -FilePath C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\deviceKey.pem -InputObject $deviceKeyPem  -Encoding ascii
Out-File -FilePath C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\userKey.pem -InputObject $userKeyPem  -Encoding ascii

#cleanup 
$certStore = Get-Item Cert:\LocalMachine\REQUEST
$certStore.open('ReadWrite')
$certStore.remove($deviceCertificate)
$certStore.remove($userCertificate)



}

#Function adapted from CloudLAPS
function Get-AzureADDeviceCert {

        Process {
            # Define Cloud Domain Join information registry path
            $AzureADJoinInfoRegistryKeyPath = "HKLM:\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"

            # Retrieve the child key name that is the thumbprint of the machine certificate containing the device identifier guid
            $AzureADJoinInfoKey = Get-ChildItem -Path $AzureADJoinInfoRegistryKeyPath | Select-Object -ExpandProperty "PSChildName"
            if ($AzureADJoinInfoKey -ne $null) {
                # Match key data against GUID regex
                if ([guid]::TryParse($AzureADJoinInfoKey, $([ref][guid]::Empty))) {
                    $AzureADJoinCertificate = Get-ChildItem -Path "Cert:\LocalMachine\My" -Recurse | Where-Object { $PSItem.Subject -like "CN=$($AzureADJoinInfoKey)" }
                }
                else {
                    $AzureADJoinCertificate = Get-ChildItem -Path "Cert:\LocalMachine\My" -Recurse | Where-Object { $PSItem.Thumbprint -eq $AzureADJoinInfoKey }    
                }

                # Retrieve the machine certificate based on thumbprint from registry key
                if ($AzureADJoinCertificate -ne $null) {
                    # Determine the device identifier from the subject name
                    $AzureADDeviceID = ($AzureADJoinCertificate | Select-Object -ExpandProperty "Subject") -replace "CN=", ""
                    
                    # Write event log entry with DeviceId
                    #Write-EventLog -LogName $EventLogName -Source $EventLogSource -EntryType Information -EventId 51 -Message "CloudLAPS: Found Cert for Device with Azure AD device identifier: $($AzureADDeviceID)"

                    # Handle return value
                    return $AzureADJoinCertificate
                }
            }
        }
    }

   

#Function adapted from CloudLAPS
function New-RSACertificateSignature {
        <#
        .SYNOPSIS
            Creates a new signature based on content passed as parameter input using the private key of a certificate, to sign the computed hash of the content.
        
        .DESCRIPTION
            Creates a new signature based on content passed as parameter input using the private key of a certificate also passed as a parameter, to sign the computed hash of the content.
            The certificate used must be available in the LocalMachine\My certificate store, and must also contain a private key.
    
        .PARAMETER Content
            Specify the content string to be signed.
    
        .PARAMETER Thumbprint
            Specify the thumbprint of the certificate.
        
        .NOTES
            Author:      Nickolaj Andersen / Thomas Kurth
            Contact:     @NickolajA
            Created:     2021-06-03
            Updated:     2021-06-03
        
            Version history:
            1.0.0 - (2021-06-03) Function created
    
            Credits to Thomas Kurth for sharing his original C# code.
        #>
        param(
            [parameter(Mandatory = $true, HelpMessage = "Specify the content string to be signed.")]
            [ValidateNotNullOrEmpty()]
            [string]$Content,
    
            [parameter(Mandatory = $true, HelpMessage = "Specify the certificate used to sign the content.")]
            [ValidateNotNullOrEmpty()]
            [system.security.cryptography.x509certificates.x509certificate2]$Certificate
        )
        Process {

        #check cert exists and private key is available
            if ($Certificate -ne $null) {
                if ($Certificate.HasPrivateKey -eq $true) {
                    # Read the RSA private key
                    $RSAPrivateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)            
                    if ($RSAPrivateKey -ne $null) {
                        if ($RSAPrivateKey -is [System.Security.Cryptography.RSACng]) {
                            # Construct a new SHA256Managed object to be used when computing the hash
                            $SHA256Managed = New-Object -TypeName "System.Security.Cryptography.SHA256Managed"
    
                            # Construct new UTF8 unicode encoding object
                            $UnicodeEncoding = [System.Text.UnicodeEncoding]::UTF8
    
                            # Convert content to byte array
                            [byte[]]$EncodedContentData = $UnicodeEncoding.GetBytes($Content)
    
                            # Compute the hash
                            [byte[]]$ComputedHash = $SHA256Managed.ComputeHash($EncodedContentData)
    
                            # Create signed signature with computed hash
                            [byte[]]$SignatureSigned = $RSAPrivateKey.SignHash($ComputedHash, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)
    
                            # Convert signature to Base64 string
                            $SignatureString = [System.Convert]::ToBase64String($SignatureSigned)

                            # Handle return value
                            return $SignatureString
                        }
                    }
                }
            }
        }
    }

#installs Ziti
Start-Process -FilePath .\Ziti.Desktop.Edge.Client -ArgumentList "/exenoui /qn /norestart" -wait

#defines the body of our HTTP request. 

$Body = [ordered]@{
            DeviceName         = ""
            DeviceEntraCert    = ""
            UserCSR            = ""
            DeviceCSR          = ""
            UserCSRSig         = ""
            DeviceCSRSig       = ""
        }
#get the device name
$body["DeviceName"] = $env:COMPUTERNAME

#get the device provisioning cert and load into data array
$DeviceEntraCert = Get-AzureADDeviceCert
$DeviceEntraCertBase64 = [System.Convert]::ToBase64String($DeviceEntraCert.RawData, [System.Base64FormattingOptions]::InsertLineBreaks)
$DeviceEntraCertPEM = @"
-----BEGIN CERTIFICATE-----
$DeviceEntraCertBase64
-----END CERTIFICATE-----
"@
$Body["DeviceEntraCert"] = $DeviceEntraCertPEM
$url = "<redacted>"
#load in signing requests
generate_keys $body

#sign the CSRs using the machine cert, to link the public keys generated to the device identity.
$Body["UserCSRSig"] = New-RSACertificateSignature -Content $body["UserCSR"] -certificate $DeviceEntraCert
$Body["DeviceCSRSig"] = New-RSACertificateSignature -Content $body["DeviceCSR"] -certificate $DeviceEntraCert

$response = Invoke-WebRequest -method "Post" -uri $url -Body ($Body | ConvertTo-Json) -ErrorVariable RespErr -ContentType 'application/json' -TimeoutSec 30 -UseBasicParsing

$json = $response.Content | ConvertFrom-Json

Out-File -FilePath C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\User.CRT -InputObject ($json.userCert)  -Encoding ascii
Out-File -FilePath C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\Device.CRT -InputObject ($json.deviceCert)  -Encoding ascii

&'C:\Program Files (x86)\NetFoundry Inc\Ziti Desktop Edge\ziti-edge-tunnel.exe' add -j "$PWD\Device.jwt" -c C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\Device.CRT -k C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\deviceKey.pem -i device-tunnel
&'C:\Program Files (x86)\NetFoundry Inc\Ziti Desktop Edge\ziti-edge-tunnel.exe' add -j "$PWD\User.jwt" -c C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\User.CRT  -k C:\Windows\System32\config\systemprofile\AppData\Roaming\NetFoundry\userKey.pem -i user-tunnel
1 Like