Smartcards and Windows' Crypto API

It's been a while since I've written anything (oops!), so to try to get into the swing of things I thought I'd talk a bit about something I ran into at work that doesn't really seem to be documented anywhere online: how to work with smartcards using Windows' crypto functions.1

Smartcards

Smartcards are, according to Wikipedia's uselessly-general definition, "any pocket-sized card that has embedded integrated circuits." The class of smartcards we care about here are the credit-card-sized devices users plug into a special reader, generally to log onto a computer. This works because the smartcard stores a token that uniquely identifies its owner; a secret PIN used to access the token acts as a second factor securing their login.

So smartcards can store information (in this case, a digital certificate). Most modern smartcards also include hardware for performing cryptographic operations, including generating keypairs, modular exponentiation for RSA, and so on. This is the functionality we want to access in this post.

While there's a ton of documentation on how to set up and use smartcards for logging in, there's very little on directly using their crypto capabilities. When implementing this for work, one of the most useful references was actually Microsoft's documentation intended for people implementing a crypto provider since it describes the sequence of calls the provider should expect for a given operation.

Windows Crypto

Windows provides a rich API for doing crypto things. We don't really need to go over this in detail, but there are some important concepts:

  • Crypt operations are performed by a Cryptographic Service Provider (CSP). CSPs provide a generic interface for creating and storing keys, implementing RSA/AES/other primitives, and so on.
  • When you acquire a reference to a CSP, you can specify a key container to access. This is exactly what it sounds like: a repository that allows you to store and retrieve keys.
  • Key containers can store two different keys. The AT_SIGNATURE key can only be used for signing data, while the AT_KEYEXCHANGE key can be used for signing or encrypting.

Really, the hardest part about using a smartcard for crypto is correctly acquiring a handle to it. After that point, you treat it like any other CryptoAPI provider. Below, I'll run through setting up a provider, retrieving its encryption key, and encrypting a piece of data.

Code

The all-important initialization call:

HANDLE hProvider = NULL;  
LPCTSTR pszContainer = _T("MyKeyContainer");

CryptAcquireContext(  
    &hProvider,
    pszContainer,
    MS_SCARD_PROV,
    PROV_RSA_FULL,
    CRYPT_MACHINE_KEYSET
);

So what's this doing? Going argument-by-argument,

  • The result is placed in hProvider.
  • We're accessing the key container called MyKeyContainer.
  • We want to use Microsoft's built-in "Base Smart Card CSP". Windows will let us choose from among the attached smartcards for the one to use.
  • We're requesting a handle to a general-purpose provider that supports using RSA for both digital signatures and data encryption.
  • The provider should use keys stored on the smartcard directly, rather than on the computer's disk or something.

After we've acquired this handle, we want to retrieve the encryption key from the smartcard:

HCRYPTKEY hKey = NULL;  
CryptGetUserKey(hProvider, AT_KEYEXCHANGE, &hKey);  

Here, we're using the handle to our smartcard CSP from above, and accessing the AT_KEYEXCHANGE key2 because we're going to encrypt data.

After we get the key, it's time to encrypt! As is common with Windows, we call CryptEncrypt twice -- once to determine how much data it will output so we can allocate a buffer for it, and once to actually save the encrypted data.

LPCSTR pszPlaintext = "Here is some sample data!";  
DWORD cbPlaintext = strlen(pszPlaintext);  
DWORD dwBufferSize = cbPlaintext;

// Compute how large our result will be.
CryptEncrypt(  
    hKey,       // Encrypt using this key
    NULL,       // Don't add this data to any hash object
    TRUE,       // There is no further data to encrypt
    0,          // This must be 0
    NULL,       // No data input; just determining the output size
    &dwBufferSize,  // Provide the amount of data in
    0           // No result buffer, so its length is 0
);

PBYTE pEncrypted = new BYTE[dwBufferSize];

// CryptEncrypt encrypts in-place, so we need to put the 
// data into our freshly-created buffer.
CopyMemory(pEncrypted, pszPlaintext, cbPlaintext);

// Actually encrypt the buffer
CryptEncrypt(  
    hKey,
    NULL,
    TRUE,
    0,
    pEncrypted,   // This time, we will be encrypting data!
    &cbPlaintext, // This much data.
    dwBufferSize  // Amount of space we can use in the buffer
);

And that's that.

Caveats

Of course, nothing is ever easy, so it's not actually that simple. Besides the error-handling we've obviously neglected, a number of issues need to be addressed.

The first big question is how we want to handle missing keys and key containers. If we only want to work with cards that have already initialized, we can just check for failure and call it good. On the other hand, if we want to handle a missing key container or key by creating them for future use, it gets significantly more complicated.

The biggest single issue I ran into here is related to UI. If we haven't used this smartcard before, our key container won't exist, and Windows will pop up a confusing window informing you that none of your attached smartcards support the desired operation. You can get around this by passing the CRYPT_SILENT flag to CryptAcquireContext; this prevents the provider from showing any UI elements.

Of course, when you try to encrypt something, Windows wants to prompt you for your smartcard PIN to validate that you're allowed to access it. However, since you told it to never ever show a UI, it can't, and fails with NTE_SILENT_CONTEXT.

To get around this, we can attempt to open the key container with CRYPT_SILENT set. If it succeeds, immediately release the handle we've just created, and reopen it in noisy mode now that we know that the key container exists and so the call should succeed. On the other hand, if opening fails in silent mode, we check if the error was NTE_BAD_KEYSET, indicating that the key container doesn't exist, and creates it if so.

In code:

if (CryptAcquireContext(&hProvider, pszContainer, MS_SCARD_PROV, PROV_RSA_FULL, CRYPT_SILENT | CRYPT_MACHINE_KEYSET)) {  
    // Opening in silent mode succeeded.
    // Close and reopen in noisy mode.
    CryptReleaseContext(hProvider, 0);
    CryptAcquireContext(&hProvider, pszContainer, MS_SCARD_PROV, PROV_RSA_FULL, CRYPT_MACHINE_KEYSET);
} else {
    // Opening failed.
    if (NTE_BAD_KEYSET == GetLastError()) {
        // The key container doesn't exist. Create it.
        CryptAcquireContext(&hProvider, pszContainer, MS_SCARD_PROV, PROV_RSA_FULL, CRYPT_NEWKEYSET);
    }
}

The next caveat is that encrypting data using pure RSA is generally not what one wants to do, as RSA is quite computationally expensive and produces very large ciphertexts. So instead we can create an exportable session key, encrypt data using that, and export the session key wrapped using the RSA keypair.

CryptGenKey(hProvider, CALG_AES_256, CRYPT_EXPORTABLE, &hAesKey);  
CryptEncrypt(hAesKey, pPlaintext, cbPlaintext, &pEncrypted, &pcbEncrypted);  
CryptExportKey(hAesKey, hRsaKey, SIMPLEBLOB, 0, pKeyblob, cbKeyblob);  

Then write out both the AES-encrypted ciphertext and the wrapped AES key. This arrangement ensures that you can only decrypt the AES key, and thus the ciphertext, if you have the smartcard and know its PIN.

  1. Exhilarating, I know.

  2. We're not actually accessing the key itself. In fact, often it's impossible to retrieve raw key data from a smartcard or other CSP. Instead, we're getting a reference to the key to use; the encryption happens on the card itself, and the key never leaves the card.