aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt12
-rw-r--r--src/keyring.cpp318
-rw-r--r--src/keyring.h27
-rw-r--r--src/tpm/tpm.h33
-rw-r--r--src/tpm/tpm_cng.c170
-rw-r--r--src/tpm/tpm_stub.c28
-rw-r--r--src/tpm/tpm_tss.c244
7 files changed, 757 insertions, 75 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b3786d9..f9143be 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -126,8 +126,20 @@ qt_add_executable(Attorney_Online
src/auth_flow.cpp
src/saved_auth.h
src/saved_auth.cpp
+ src/tpm/tpm.h
)
+if(WIN32)
+ target_sources(Attorney_Online PRIVATE src/tpm/tpm_cng.c)
+ target_link_libraries(Attorney_Online PRIVATE ncrypt rpcrt4)
+elseif(UNIX AND NOT APPLE)
+ target_sources(Attorney_Online PRIVATE src/tpm/tpm_tss.c)
+ target_link_libraries(Attorney_Online PRIVATE tss2-esys tss2-mu)
+ target_compile_definitions(Attorney_Online PRIVATE SOF_AO_HARDWARE_KEY_REQUIRES_PIN_UI)
+else()
+ target_sources(Attorney_Online PRIVATE src/tpm/tpm_stub.c)
+endif()
+
if(CMAKE_BUILD_TYPE STREQUAL "Dev")
target_compile_definitions(Attorney_Online PRIVATE NETWORK_DEBUG)
target_compile_options(Attorney_Online PRIVATE -O0 -Wall -Wextra -pedantic)
diff --git a/src/keyring.cpp b/src/keyring.cpp
index eb71e53..324aec2 100644
--- a/src/keyring.cpp
+++ b/src/keyring.cpp
@@ -16,6 +16,10 @@
#include "file_functions.h"
#include "keyring.h"
+extern "C" {
+#include "tpm/tpm.h"
+}
+
// Encrypted secret key serialized format version.
//
// Version 1: Secret key encrypted with ChaCha20-Poly1305 using password-based
@@ -45,6 +49,10 @@ static constexpr quint8 all_zero_nonce[crypto_aead_chacha20poly1305_NPUBBYTES] =
// authentication mechanism.
static const auto domain_sep = QByteArrayLiteral("Einsof-Auth-DHCR");
+// Internal state, set by tpm2_init if TPM check and self-test succeed, and we
+// can use hardware keys.
+static int tpm_available = 0;
+
static QString get_keyring_path()
{
return QDir(get_app_path()).filePath("keyring.cbor");
@@ -67,10 +75,73 @@ int keyring_initialize()
index.close();
}
+ if (tpm2_init() == STKB_SUCCESS)
+ {
+ tpm_available = 1;
+ }
+
+ return 0;
+}
+
+// Expose a way to query status without making the internal state variable
+// global.
+// Yes, this is stupid.
+int keyring_hardware_available()
+{
+ return tpm_available;
+}
+
+// Prepare the key CBOR entry and atomically insert it into the keyring.
+// To ensure durability, file locking and atomic overwrite are used as
+// provided by Qt. Frankly, I don't see the keyring being prone to corruption
+// as there's no concurrent access, save for a contrived edge case of a user
+// generating/deleting keys simultaneously from two client instances. It's
+// done regardless for a good measure.
+static int insert_into_keyring(quint8 tag, QStringView name, const QByteArray &pub, const QByteArray &sec)
+{
+ QCborMap entry;
+ entry.insert(0, QCborValue(tag));
+ entry.insert(1, QCborValue(name));
+ entry.insert(2, QCborValue(pub));
+ entry.insert(3, QCborValue(sec));
+ QFile index_file(get_keyring_path());
+ QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
+ if (!index_lock.lock())
+ {
+ return 1;
+ }
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return 1;
+ }
+ QCborStreamReader idx_in(&index_file);
+ QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ if (!index.isMap())
+ {
+ index = QCborMap();
+ }
+ QCborMap index_map = index.toMap();
+ // Key fingerprint is used as its unique identifier.
+ QByteArray fingerprint;
+ fingerprint.append(tag);
+ fingerprint.append(pub);
+ uchar fpr_hash[crypto_generichash_BYTES];
+ crypto_generichash(fpr_hash, sizeof(fpr_hash), (const uchar *)fingerprint.constData(), fingerprint.size(), nullptr, 0);
+ index_map.insert(QCborValue(QByteArray((const char *)fpr_hash, (qsizetype)sizeof(fpr_hash))), QCborValue(entry));
+ QSaveFile index_file_w(get_keyring_path());
+ if (!index_file_w.open(QIODevice::WriteOnly))
+ {
+ return 1;
+ }
+ index_file_w.write(QCborValue(index_map).toCbor());
+ index_file_w.commit();
+ index_lock.unlock();
return 0;
}
-int generate_key(QStringView name, const QByteArray &password)
+// Software X25519 keys protected by a passphrase.
+int generate_software_key(QStringView name, const QByteArray &password)
{
// If we let users specify arbitrary parameters, additional constraints must
// be enforced: min/max opslimit, type of memlimit (uint32_t vs size_t).
@@ -127,94 +198,73 @@ int generate_key(QStringView name, const QByteArray &password)
}
sodium_munlock(wrap_key, sizeof(wrap_key));
sodium_munlock(secret_key, sizeof(secret_key));
- QByteArray public_key_array((const char *)public_key, (qsizetype)sizeof(public_key));
- quint8 tag = cert_x25519_tag;
-
- // Key fingerprint is used as its unique identifier.
- QByteArray fingerprint;
- fingerprint.append(tag);
- fingerprint.append(public_key_array);
- uchar fpr_hash[crypto_generichash_BYTES];
- crypto_generichash(fpr_hash, sizeof(fpr_hash), (const uchar *)fingerprint.constData(), fingerprint.size(), nullptr, 0);
+ QByteArray public_key_array((const char *)public_key, (qsizetype)sizeof(public_key));
QByteArray encrypted_secret(packed_header);
encrypted_secret.append((const char *)payload, sizeof(payload));
- // Prepare the key CBOR entry and atomically insert it into the keyring.
- // To ensure durability, file locking and atomic overwrite are used as
- // provided by Qt. Frankly, I don't see the keyring being prone to corruption
- // as there's no concurrent access, save for a contrived edge case of a user
- // generating/deleting keys simultaneously from two client instances. It's
- // done regardless for a good measure.
- QCborMap entry;
- entry.insert(0, QCborValue(tag));
- entry.insert(1, QCborValue(name));
- entry.insert(2, QCborValue(public_key_array));
- entry.insert(3, QCborValue(encrypted_secret));
- QFile index_file(get_keyring_path());
- QLockFile index_lock(QDir(get_app_path()).filePath("keyring.lock"));
- if (!index_lock.lock())
+ if (insert_into_keyring(cert_x25519_tag, name, public_key_array, encrypted_secret))
{
- return 9;
+ return 5;
}
- if (!index_file.open(QIODevice::ReadOnly))
- {
- return 6;
- }
- QCborStreamReader idx_in(&index_file);
- QCborValue index = QCborValue::fromCbor(idx_in);
- index_file.close();
- if (!index.isMap())
- {
- index = QCborMap();
- }
- QCborMap index_map = index.toMap();
- index_map.insert(QCborValue(QByteArray((const char *)fpr_hash, (qsizetype)sizeof(fpr_hash))), QCborValue(entry));
- QSaveFile index_file_w(get_keyring_path());
- if (!index_file_w.open(QIODevice::WriteOnly))
- {
- return 8;
- }
- index_file_w.write(QCborValue(index_map).toCbor());
- index_file_w.commit();
- index_lock.unlock();
return 0;
}
-// Look up the key_id in the keyring, unlock it with supplied password using
-// parameters from its header, perform a Diffie-Hellman key exchange with the
-// server's ephemeral key to derive a shared secret, and hash it along with
-// additional data to prove your authenticity to the server.
-ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out)
+static constexpr quint8 cert_hwkey_p256_tag = 0x33;
+
+static std::wstring optional_hardware_key_name(QStringView name)
{
- if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
- {
- return ResponseResult::invalid_password;
- }
+#ifdef SOF_AO_HARDWARE_KEY_REQUIRES_PIN_UI
+ (void)name;
+ return std::wstring();
+#else
+ return name.toString().toStdWString();
+#endif
+}
- QFile index_file(get_keyring_path());
- if (!index_file.open(QIODevice::ReadOnly))
- {
- return ResponseResult::inaccessible_keyring;
+// Hardware P-256 keys (because most TPMs can only do P-256, unfortunately);
+// the TPM verifies the PIN.
+int generate_hardware_key(QStringView name, const QByteArray &pin)
+{
+ // Compressed P-256 public key.
+ uint8_t pub[33];
+ // Protected secret blobs differ by size depending on the platform (and in
+ // case of TSS2 implementation, size is unspecified). Prepare a sufficient
+ // buffer to write to, the actual output size will be in `sec_written`.
+ uint8_t sec[512];
+ size_t sec_written = 0;
+ std::wstring optional_name_wstr = optional_hardware_key_name(name);
+ stkb_user_input input = {
+ .pin = (const uint8_t *)pin.constData(),
+ .pin_len = (size_t)pin.size(),
+ .key_name = optional_name_wstr.c_str(),
+ };
+ stkb_rc rc = tpm2_keygen(pub, sec, sizeof(sec), &sec_written, input);
+ switch (rc) {
+ case STKB_SUCCESS:
+ break;
+ default:
+ return rc;
}
- QCborStreamReader idx_in(&index_file);
- QCborValue index = QCborValue::fromCbor(idx_in);
- index_file.close();
- QCborMap tmp_map = index.toMap().value(QCborValue(QByteArray(key_id))).toMap();
- QCborValue val = tmp_map.value(3);
- if (!val.isByteArray())
+ QByteArray public_array((const char *)pub, (qsizetype)sizeof(pub));
+ QByteArray protected_array((const char *)sec, (qsizetype)sec_written);
+ if (insert_into_keyring(cert_hwkey_p256_tag, name, public_array, protected_array))
+ return 1;
+ return 0;
+}
+
+static ResponseResult compute_with_software_key(quint8 *shared_secret, const QByteArray &encrypted_secret, QByteArrayView password, QByteArrayView ephemeral_key)
+{
+ if (password.size() < crypto_pwhash_PASSWD_MIN || password.size() > crypto_pwhash_PASSWD_MAX)
{
- return ResponseResult::corrupted_entry;
+ return ResponseResult::invalid_password;
}
- QByteArray encrypted_secret = val.toByteArray();
- val = tmp_map.value(2);
- if (!val.isByteArray())
+ if (ephemeral_key.size() != crypto_kx_PUBLICKEYBYTES)
{
- return ResponseResult::corrupted_entry;
+ return ResponseResult::incompatible_arguments;
}
- QByteArray public_key = val.toByteArray();
// After retrieving the key from the keyring, the process is the same as
// in key generation, but in the opposite direction.
@@ -264,7 +314,6 @@ ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, Q
// Now we've unlocked the key, and ready to perform DH with the server's
// ephemeral key to prove ourselves.
- quint8 shared_secret[crypto_scalarmult_BYTES];
if (crypto_scalarmult(shared_secret, secret_key, (const uchar *)ephemeral_key.constData()))
{
// We ended up with the point at infinity, something stupid must've
@@ -274,6 +323,126 @@ ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, Q
}
sodium_munlock(secret_key, sizeof(secret_key));
+ return ResponseResult::success;
+}
+
+static ResponseResult compute_with_hardware_key(quint8 *shared_secret, const QByteArray &blob, QByteArrayView pin, QByteArrayView ephemeral_key)
+{
+ if (ephemeral_key.size() != 64)
+ {
+ return ResponseResult::incompatible_arguments;
+ }
+ stkb_user_input input = {
+ .pin = (const uint8_t *)pin.constData(),
+ .pin_len = (size_t)pin.size(),
+ .key_name = nullptr,
+ };
+ stkb_rc rc = tpm2_compute_ss(shared_secret, (const uint8_t *)blob.constData(), (size_t)blob.size(), (const uint8_t *)ephemeral_key.constData(), input);
+ switch (rc) {
+ case STKB_SUCCESS:
+ return ResponseResult::success;
+ case STKB_ERROR:
+ return ResponseResult::hardware_fault;
+ case STKB_BAD_PIN:
+ case STKB_AUTH_FAILURE:
+ return ResponseResult::invalid_pin;
+ case STKB_LOCKOUT:
+ return ResponseResult::hardware_lockout;
+ case STKB_TPM_UNAVAILABLE:
+ default:
+ return ResponseResult::inaccessible_keyring;
+ }
+}
+
+KeyringKey acquire_keyring_key(QByteArrayView key_id)
+{
+ struct KeyringKey key;
+ QFile index_file(get_keyring_path());
+ if (!index_file.open(QIODevice::ReadOnly))
+ {
+ return key;
+ }
+ QCborStreamReader idx_in(&index_file);
+ QCborValue index = QCborValue::fromCbor(idx_in);
+ index_file.close();
+ QCborMap tmp_map = index.toMap().value(QCborValue(QByteArray(key_id))).toMap();
+
+ QCborValue val = tmp_map.value(2);
+ if (!val.isByteArray())
+ {
+ return key;
+ }
+ key.public_key = val.toByteArray();
+
+ val = tmp_map.value(3);
+ if (!val.isByteArray())
+ {
+ return key;
+ }
+ key.protected_secret = val.toByteArray();
+
+ val = tmp_map.value(0);
+ if (!val.isInteger())
+ {
+ return key;
+ }
+ qint64 raw_tag = val.toInteger();
+ if (raw_tag < 0 || raw_tag > 255)
+ {
+ return key;
+ }
+ quint8 tag = (quint8)raw_tag;
+ switch (tag) {
+ case cert_x25519_tag:
+ key.type = KeyringStorageType::software;
+ break;
+ case cert_hwkey_p256_tag:
+ key.type = KeyringStorageType::hardware;
+ break;
+ default:
+ break;
+ }
+
+ val = tmp_map.value(1);
+ if (!val.isString())
+ {
+ return key;
+ }
+ key.name = val.toString();
+
+
+ key.id = QByteArray(key_id);
+
+ return key;
+}
+
+// Look up the key_id in the keyring, unlock it with supplied password using
+// parameters from its header, perform a Diffie-Hellman key exchange with the
+// server's ephemeral key to derive a shared secret, and hash it along with
+// additional data to prove your authenticity to the server.
+ResponseResult unlock_and_auth(const KeyringKey &key, QByteArrayView input, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out)
+{
+ uint8_t shared_secret[32];
+ sodium_mlock(shared_secret, sizeof(shared_secret));
+
+ ResponseResult result;
+ switch (key.type) {
+ case KeyringStorageType::software:
+ result = compute_with_software_key(shared_secret, key.protected_secret, input, ephemeral_key);
+ break;
+ case KeyringStorageType::hardware:
+ result = compute_with_hardware_key(shared_secret, key.protected_secret, input, ephemeral_key);
+ break;
+ default:
+ result = ResponseResult::corrupted_entry;
+ break;
+ }
+ if (result != ResponseResult::success)
+ {
+ sodium_munlock(shared_secret, sizeof(shared_secret));
+ return result;
+ }
+
// The proof is a BLAKE2b-256 hash over the following:
// 1. Domain-separating constant.
// 2. Shared DH secret, serving as a proof of posession of the secret key
@@ -297,11 +466,14 @@ ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, Q
crypto_generichash_update(&state, (const uchar *)domain_sep.constData(), domain_sep.size());
crypto_generichash_update(&state, shared_secret, sizeof(shared_secret));
crypto_generichash_update(&state, (const uchar *)ephemeral_key.constData(), ephemeral_key.size());
- crypto_generichash_update(&state, (const uchar *)public_key.constData(), public_key.size());
+ crypto_generichash_update(&state, (const uchar *)key.public_key.constData(), key.public_key.size());
crypto_generichash_update(&state, (const uchar *)username.constData(), username.size());
crypto_generichash_final(&state, proof, sizeof(proof));
out.response = QByteArray((const char *)proof, (qsizetype)sizeof(proof));
+
+ sodium_munlock(shared_secret, sizeof(shared_secret));
+
return ResponseResult::success;
}
diff --git a/src/keyring.h b/src/keyring.h
index 81d97fc..42cf992 100644
--- a/src/keyring.h
+++ b/src/keyring.h
@@ -42,9 +42,32 @@ enum class ResponseResult
derivation_failed,
decryption_failed,
bad_curve_point,
+ hardware_fault,
+ invalid_pin,
+ hardware_lockout,
+ incompatible_arguments,
+};
+
+enum class KeyringStorageType
+{
+ invalid,
+ software,
+ hardware,
+};
+
+struct KeyringKey
+{
+ KeyringStorageType type = KeyringStorageType::invalid;
+ QByteArray id;
+ QByteArray public_key;
+ QByteArray protected_secret;
+ QString name;
};
int keyring_initialize(void);
-int generate_key(QStringView name, const QByteArray &password);
+int keyring_hardware_available(void);
+int generate_software_key(QStringView name, const QByteArray &password);
+int generate_hardware_key(QStringView name, const QByteArray &pin);
+KeyringKey acquire_keyring_key(QByteArrayView key_id);
void delete_key(const QByteArray &key_id);
-ResponseResult unlock_and_auth(QByteArrayView key_id, QByteArrayView password, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out);
+ResponseResult unlock_and_auth(const KeyringKey &key, QByteArrayView password, QByteArrayView ephemeral_key, QByteArrayView username, AuthResponse &out);
diff --git a/src/tpm/tpm.h b/src/tpm/tpm.h
new file mode 100644
index 0000000..7531ae2
--- /dev/null
+++ b/src/tpm/tpm.h
@@ -0,0 +1,33 @@
+// Copyright 2026 Osmium Sorcerer
+// SPDX-License-Identifier: MIT
+
+#ifndef SOF_AO2CLIENT_TPM_TPM_H
+#define SOF_AO2CLIENT_TPM_TPM_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+typedef enum {
+ STKB_SUCCESS,
+ STKB_ERROR,
+ STKB_TPM_UNAVAILABLE,
+ STKB_BAD_PIN,
+ STKB_AUTH_FAILURE,
+ STKB_LOCKOUT,
+} stkb_rc;
+
+typedef struct {
+ const uint8_t *pin;
+ size_t pin_len;
+ const wchar_t *key_name;
+} stkb_user_input;
+
+stkb_rc tpm2_init(void);
+
+stkb_rc tpm2_keygen(uint8_t *out_pub, uint8_t *out_buffer, size_t buffer_size,
+ size_t *written, stkb_user_input input);
+
+stkb_rc tpm2_compute_ss(uint8_t *ss, const uint8_t *blob, size_t blob_len,
+ const uint8_t *pk, stkb_user_input input);
+
+#endif /* SOF_AO2CLIENT_TPM_TPM_H */
diff --git a/src/tpm/tpm_cng.c b/src/tpm/tpm_cng.c
new file mode 100644
index 0000000..1f764c7
--- /dev/null
+++ b/src/tpm/tpm_cng.c
@@ -0,0 +1,170 @@
+// Copyright 2026 Osmium Sorcerer
+// SPDX-License-Identifier: MIT
+
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+#include <ncrypt.h>
+#include <rpc.h>
+
+#include "tpm.h"
+
+// Global handle for the Windows API.
+static NCRYPT_PROV_HANDLE provider;
+
+// 9 characters for prefix, 36 for random UUID, and don't forget that null terminator.
+// Note: this is count of wchar_t, not size.
+static const size_t keyname_len = 46;
+
+stkb_rc tpm2_init(void)
+{
+ // "Microsoft Platform Crypto Provider" is the TPM.
+ if (NCryptOpenStorageProvider(&provider, MS_PLATFORM_CRYPTO_PROVIDER, 0) != ERROR_SUCCESS)
+ return STKB_TPM_UNAVAILABLE;
+ if (NCryptIsAlgSupported(provider, BCRYPT_ECDH_P256_ALGORITHM, 0) != ERROR_SUCCESS)
+ return STKB_TPM_UNAVAILABLE;
+ return STKB_SUCCESS;
+}
+
+
+static int generate_keyname(wchar_t *out)
+{
+ // UUID seems like the way on Windows.
+ UUID id;
+ RPC_WSTR wstr = NULL;
+
+ if (UuidCreate(&id) != RPC_S_OK)
+ return 0;
+
+ if (UuidToStringW(&id, &wstr) != RPC_S_OK)
+ return 0;
+
+ wcscpy(out, L"SoF_Auth_");
+ wcscpy(out + 9, wstr);
+
+ RpcStringFreeW(&wstr);
+
+ return 1;
+}
+
+stkb_rc tpm2_keygen(uint8_t *out_pub, uint8_t *out_blob, size_t buffer_size, size_t *offset,
+ stkb_user_input input)
+{
+ NCRYPT_KEY_HANDLE key_handle = 0;
+ // Windows heavily abstracts the TPM and prevents you from acquiring the encrypted private structure. Instead, you
+ // have to use wide null-terminated strings as canonical key identifiers and refer to them by these names later.
+ // Yes, not even opaque byte arrays despite it handling "BLOBs."
+ wchar_t keyname[keyname_len];
+ if (sizeof(keyname) > buffer_size)
+ return STKB_ERROR;
+ if (!generate_keyname(keyname))
+ return STKB_ERROR;
+
+ // One might think, when NULL is passed instead of a key name, the key will not actually be persistent. But it
+ // becomes impossible to export the key to be loaded later, even in an encrypted form (while NCryptExportKey
+ // documents NCRYPT_OPAQUETRANSPORT_BLOB, explicitly saying "Opaque BLOBs are not transferable and must be imported
+ // by using the same CSP," and NCRYPT_PROTECTED_KEY_BLOB, confusingly, also doesn't export an opaque fixed-TPM
+ // restricted key blob. So we are forced to use a unique (potentially user-facing) name as an internal specifier.
+ if (NCryptCreatePersistedKey(provider, &key_handle, BCRYPT_ECDH_P256_ALGORITHM, keyname, 0, 0) != ERROR_SUCCESS)
+ return STKB_ERROR;
+
+ // Leave PINs unused, let the platform handle the input dialog.
+ NCRYPT_UI_POLICY ui_policy = {
+ .dwVersion = 1,
+ .dwFlags = NCRYPT_UI_FORCE_HIGH_PROTECTION_FLAG,
+ .pszFriendlyName = input.key_name,
+ .pszDescription = L"SoF authentication key",
+ };
+ stkb_rc ret = STKB_ERROR;
+ if (NCryptSetProperty(key_handle, NCRYPT_UI_POLICY_PROPERTY, (PBYTE)&ui_policy, sizeof(ui_policy), 0) != ERROR_SUCCESS)
+ goto exit;
+
+ // If the TPM is locked out, of all functions, FinalizeKey fails by returning NTE_INVALID_HANDLE.
+ // The other way it can happen (memory corruption aside) is if the user cancels the key creation
+ // dialog. You can't differentiate the two.
+ if (NCryptFinalizeKey(key_handle, 0) != ERROR_SUCCESS) {
+ ret = STKB_LOCKOUT;
+ goto exit;
+ }
+
+ BCRYPT_ECCKEY_BLOB ecc_blob = { 0 };
+ BYTE pub_buffer[sizeof(ecc_blob) + 32 * 2];
+ DWORD pub_buffer_off = 0;
+ if (NCryptExportKey(key_handle, 0, BCRYPT_ECCPUBLIC_BLOB, NULL, pub_buffer, sizeof(pub_buffer), &pub_buffer_off,
+ 0) != ERROR_SUCCESS)
+ goto exit;
+ memcpy(&ecc_blob, pub_buffer, sizeof(ecc_blob));
+ // Check sanity of the exported public key.
+ if (ecc_blob.dwMagic != BCRYPT_ECDH_PUBLIC_P256_MAGIC || ecc_blob.cbKey != 32)
+ goto exit;
+ // The x and y _should_ follow the blob (which is the beginning of the buffer) contiguously, each of cbKey (32)
+ // bytes.
+ memcpy(out_pub + 1, pub_buffer + sizeof(ecc_blob), 32);
+ // Encode parity of the last byte of y (both coordinates are big-endian).
+ if (pub_buffer[sizeof(ecc_blob) + 32 + 31] & 1)
+ out_pub[0] = 0x03;
+ else
+ out_pub[0] = 0x02;
+
+ memcpy(out_blob, keyname, sizeof(keyname));
+ *offset = sizeof(keyname);
+
+ ret = STKB_SUCCESS;
+
+exit:
+ NCryptFreeObject(key_handle);
+ return ret;
+}
+
+stkb_rc tpm2_compute_ss(uint8_t *ss, const uint8_t *blob, size_t blob_len, const uint8_t *pk, stkb_user_input input)
+{
+ (void)input;
+ // Because wchar_t is 16-byte-aligned, we can't cast a byte array, we have to do this redundant copy.
+ wchar_t keyname[keyname_len];
+ if (blob_len != sizeof(keyname))
+ return STKB_ERROR;
+ memcpy(keyname, blob, sizeof(keyname));
+ stkb_rc ret = STKB_ERROR;
+ NCRYPT_KEY_HANDLE key_handle = 0;
+ NCRYPT_KEY_HANDLE pk_handle = 0;
+ NCRYPT_SECRET_HANDLE shared_point = 0;
+ if (NCryptOpenKey(provider, &key_handle, keyname, 0, 0) != ERROR_SUCCESS)
+ goto exit;
+ // The API expects us to do the same ceremony to import the key in its format instead of using it directly.
+ BCRYPT_ECCKEY_BLOB ecc_blob = {
+ .dwMagic = BCRYPT_ECDH_PUBLIC_P256_MAGIC,
+ .cbKey = 32,
+ };
+ BYTE pub_buffer[sizeof(ecc_blob) + 32 * 2];
+ memcpy(pub_buffer, &ecc_blob, sizeof(ecc_blob));
+ memcpy(pub_buffer + sizeof(ecc_blob), pk, 64);
+ if (NCryptImportKey(provider, 0, BCRYPT_ECCPUBLIC_BLOB, NULL, &pk_handle, pub_buffer, sizeof(pub_buffer), 0) != ERROR_SUCCESS)
+ goto exit;
+ // Here it can also fail due to cancelation of dialog, too many failed attempts, or a TPM lockout.
+ if (NCryptSecretAgreement(key_handle, pk_handle, &shared_point, 0) != ERROR_SUCCESS) {
+ ret = STKB_LOCKOUT;
+ goto exit;
+ }
+ // You cannot extract the x coordinate of the shared point either, you have to derive the key. Even if you want to
+ // directly use the secret. But fine, apparently RAW_SECRET is a cryptographic key derivation function, which is
+ // some unrecognized cryptographic genius. We'll use it to get our affine big-endian x coordinate, the canonical
+ // ECDH shared secret. If you specify the size to be 32, it will only output the x coordinate, after all, that's
+ // what a point is: 32 bytes of x, then 32 bytes of y, no padding, no leading bytes, no headers. Right?
+ BYTE secret[32];
+ DWORD written = 0;
+ if (NCryptDeriveKey(shared_point, BCRYPT_KDF_RAW_SECRET, NULL, secret, sizeof(secret), &written, 0) != ERROR_SUCCESS)
+ goto exit;
+ // Of course it wouldn't be that easy. Even if we guessed the behavior of this function with respect to the buffer
+ // size you pass to it, CNG API decides that it's a good idea to output point coordinates in little-endian order,
+ // opposite of how the standard defines it (and what every other implementation correctly does, including the
+ // Platform Crypto backend that CNG relies on, and CNG's own NCryptExportKey).
+ for (size_t i = 0; i < sizeof(secret); ++i)
+ ss[i] = secret[sizeof(secret) - 1 - i];
+
+ ret = STKB_SUCCESS;
+
+exit:
+ NCryptFreeObject(key_handle);
+ NCryptFreeObject(pk_handle);
+ NCryptFreeObject(shared_point);
+ return ret;
+}
diff --git a/src/tpm/tpm_stub.c b/src/tpm/tpm_stub.c
new file mode 100644
index 0000000..4e2d2c0
--- /dev/null
+++ b/src/tpm/tpm_stub.c
@@ -0,0 +1,28 @@
+#include "tpm.h"
+
+stkb_rc tpm2_init(void)
+{
+ return STKB_TPM_UNAVAILABLE;
+}
+
+stkb_rc tpm2_keygen(uint8_t *out_pub, uint8_t *out_buffer, size_t buffer_size,
+ size_t *written, stkb_user_input input)
+{
+ (void)out_pub;
+ (void)out_buffer;
+ (void)buffer_size;
+ (void)written;
+ (void)input;
+ return STKB_TPM_UNAVAILABLE;
+}
+
+stkb_rc tpm2_compute_ss(uint8_t *ss, const uint8_t *blob, size_t blob_len,
+ const uint8_t *pk, stkb_user_input input)
+{
+ (void)ss;
+ (void)blob;
+ (void)blob_len;
+ (void)pk;
+ (void)input;
+ return STKB_TPM_UNAVAILABLE;
+}
diff --git a/src/tpm/tpm_tss.c b/src/tpm/tpm_tss.c
new file mode 100644
index 0000000..880fa32
--- /dev/null
+++ b/src/tpm/tpm_tss.c
@@ -0,0 +1,244 @@
+// Copyright 2026 Osmium Sorcerer
+// SPDX-License-Identifier: MIT
+
+#include <string.h>
+
+#include <tss2/tss2_esys.h>
+#include <tss2/tss2_mu.h>
+
+#include "tpm.h"
+
+// Storage Key: asymmetric scheme is NULL, symmetric is defined and shall
+// use CFB mode. The key is restricted.
+static const TPM2B_PUBLIC primary_template = {
+ .publicArea = {
+ .type = TPM2_ALG_ECC,
+ .nameAlg = TPM2_ALG_SHA256,
+ .objectAttributes =
+ TPMA_OBJECT_FIXEDTPM |
+ TPMA_OBJECT_FIXEDPARENT |
+ TPMA_OBJECT_SENSITIVEDATAORIGIN |
+ TPMA_OBJECT_USERWITHAUTH |
+ TPMA_OBJECT_RESTRICTED |
+ TPMA_OBJECT_DECRYPT,
+ .parameters.eccDetail = {
+ .symmetric = {
+ .algorithm = TPM2_ALG_AES,
+ .keyBits.aes = 128,
+ .mode.aes = TPM2_ALG_CFB,
+ },
+ .scheme.scheme = TPM2_ALG_NULL,
+ .curveID = TPM2_ECC_NIST_P256,
+ .kdf.scheme = TPM2_ALG_NULL,
+ },
+ },
+};
+
+// Symmetric algorithm is NULL for a nonrestricted key. This is the template
+// for actual key exchange keys under the primary storage key, accessed with a
+// user authentication value.
+// There is TPM2_ALG_ECDH scheme, but it requires KDF.
+static const TPM2B_PUBLIC ecdh_key_template = {
+ .publicArea = {
+ .type = TPM2_ALG_ECC,
+ .nameAlg = TPM2_ALG_SHA256,
+ .objectAttributes =
+ TPMA_OBJECT_FIXEDTPM |
+ TPMA_OBJECT_FIXEDPARENT |
+ TPMA_OBJECT_SENSITIVEDATAORIGIN |
+ TPMA_OBJECT_USERWITHAUTH |
+ TPMA_OBJECT_DECRYPT,
+ .parameters.eccDetail = {
+ .symmetric.algorithm = TPM2_ALG_NULL,
+ .scheme.scheme = TPM2_ALG_NULL,
+ .curveID = TPM2_ECC_NIST_P256,
+ .kdf.scheme = TPM2_ALG_NULL,
+ },
+ },
+};
+
+static const TPM2B_SENSITIVE_CREATE empty_in_sensitive = { 0 };
+static const TPM2B_DATA empty_outside_info = { 0 };
+static const TPML_PCR_SELECTION empty_creation_pcr = { 0 };
+
+// Make sure TPM 2.0 is present and working correctly before using it.
+stkb_rc tpm2_init(void)
+{
+ int ret = 0;
+ ESYS_CONTEXT *ctx;
+ if (Esys_Initialize(&ctx, NULL, NULL) != TSS2_RC_SUCCESS)
+ return STKB_TPM_UNAVAILABLE;
+ // Simple self-test. Tests only what's necessary instead of all internal
+ // functions and operations, doesn't disrupt the system.
+ ret = Esys_SelfTest(ctx, ESYS_TR_NONE, ESYS_TR_NONE, ESYS_TR_NONE, TPM2_NO);
+ Esys_Finalize(&ctx);
+ if (ret != TSS2_RC_SUCCESS)
+ return STKB_TPM_UNAVAILABLE;
+ return STKB_SUCCESS;
+}
+
+static TSS2_RC tpm2_setup_primary(ESYS_CONTEXT **ctx, ESYS_TR *primary_handle)
+{
+ TSS2_RC rc = Esys_Initialize(ctx, NULL, NULL);
+ if (rc != TSS2_RC_SUCCESS)
+ return rc;
+ rc = Esys_CreatePrimary(*ctx, ESYS_TR_RH_OWNER, ESYS_TR_PASSWORD,
+ ESYS_TR_NONE, ESYS_TR_NONE, &empty_in_sensitive,
+ &primary_template, &empty_outside_info,
+ &empty_creation_pcr, primary_handle, NULL, NULL,
+ NULL, NULL);
+ if (rc != TSS2_RC_SUCCESS)
+ Esys_Finalize(ctx);
+ return rc;
+}
+
+static stkb_rc decode_tpm_rc(TSS2_RC tpm_rc)
+{
+ // Check that the code is from the TPM itself rather than ESAPI, otherwise
+ // return a generic error as we're not interested in details.
+ if ((tpm_rc & TSS2_RC_LAYER_MASK) != TSS2_TPM_RC_LAYER)
+ return STKB_ERROR;
+
+ // Format-One return codes are composite and in particular have an added
+ // parameter value that is irrelevant to the nature of the error, clear it.
+ if (tpm_rc & TPM2_RC_FMT1)
+ tpm_rc &= ~TPM2_RC_N_MASK;
+
+ switch (tpm_rc) {
+ case TPM2_RC_BAD_AUTH:
+ case TPM2_RC_AUTH_FAIL:
+ return STKB_AUTH_FAILURE;
+ case TPM2_RC_LOCKOUT:
+ return STKB_LOCKOUT;
+ default:
+ return STKB_ERROR;
+ }
+}
+
+stkb_rc tpm2_keygen(uint8_t *out_pub, uint8_t *out_buffer, size_t buffer_size,
+ size_t *written, stkb_user_input input)
+{
+ if (input.pin_len > TPM2_SHA256_DIGEST_SIZE)
+ return STKB_BAD_PIN;
+ TPM2B_SENSITIVE_CREATE in_sensitive = { 0 };
+ memcpy(in_sensitive.sensitive.userAuth.buffer, input.pin, input.pin_len);
+ in_sensitive.sensitive.userAuth.size = (UINT16)input.pin_len;
+ ESYS_CONTEXT *ctx;
+ ESYS_TR primary_handle;
+ stkb_rc ret = STKB_ERROR;
+ if (tpm2_setup_primary(&ctx, &primary_handle) != TSS2_RC_SUCCESS)
+ return ret;
+ TPM2B_PRIVATE *ecdh_priv = NULL;
+ TPM2B_PUBLIC *ecdh_pub = NULL;
+ TSS2_RC rc = Esys_Create(ctx, primary_handle, ESYS_TR_PASSWORD,
+ ESYS_TR_NONE, ESYS_TR_NONE, &in_sensitive,
+ &ecdh_key_template, &empty_outside_info,
+ &empty_creation_pcr, &ecdh_priv, &ecdh_pub, NULL,
+ NULL, NULL);
+ if (rc != TSS2_RC_SUCCESS) {
+ ret = decode_tpm_rc(rc);
+ goto exit;
+ }
+
+ // TPM 2.0 Library, Part 1, 44.5.3 Padding:
+ //
+ // > In ECC points returned by the TPM, the x and y values, if non-empty,
+ // > are required to be the size of their associated curve (e.g., 32 bytes
+ // > for NIST P-256).
+ if (ecdh_pub->publicArea.unique.ecc.x.size != 32 ||
+ ecdh_pub->publicArea.unique.ecc.y.size != 32)
+ goto exit;
+
+ // Compress the public key: knowing x, it's enough to only encode parity of
+ // y to fully reconstruct the point.
+ if (ecdh_pub->publicArea.unique.ecc.y.buffer[31] & 1)
+ out_pub[0] = 0x03;
+ else
+ out_pub[0] = 0x02;
+ memcpy(out_pub + 1, ecdh_pub->publicArea.unique.ecc.x.buffer, 32);
+
+ // Serialize both structures, we'll need them for Load.
+ size_t off = 0;
+ if (Tss2_MU_TPM2B_PRIVATE_Marshal(ecdh_priv, out_buffer, buffer_size, &off) !=
+ TSS2_RC_SUCCESS)
+ goto exit;
+ if (Tss2_MU_TPM2B_PUBLIC_Marshal(ecdh_pub, out_buffer, buffer_size - off,
+ &off) != TSS2_RC_SUCCESS)
+ goto exit;
+
+ *written = off;
+ ret = STKB_SUCCESS;
+
+exit:
+ Esys_Free(ecdh_priv);
+ Esys_Free(ecdh_pub);
+ Esys_FlushContext(ctx, primary_handle);
+ Esys_Finalize(&ctx);
+ return ret;
+}
+
+// pk is an uncompressed 64-byte point (x || y) of the peer.
+stkb_rc tpm2_compute_ss(uint8_t *ss, const uint8_t *blob, size_t blob_len,
+ const uint8_t *pk, stkb_user_input input)
+{
+ if (input.pin_len > TPM2_SHA256_DIGEST_SIZE)
+ return STKB_BAD_PIN;
+ TPM2B_AUTH auth_value = { 0 };
+ memcpy(auth_value.buffer, input.pin, input.pin_len);
+ auth_value.size = (UINT16)input.pin_len;
+
+ ESYS_CONTEXT *ctx;
+ ESYS_TR primary_handle;
+ stkb_rc ret = STKB_ERROR;
+ if (tpm2_setup_primary(&ctx, &primary_handle) != TSS2_RC_SUCCESS)
+ return ret;
+
+ ESYS_TR ecdh_key_handle = ESYS_TR_NONE;
+ TPM2B_PRIVATE ecdh_priv;
+ TPM2B_PUBLIC ecdh_pub;
+ TPM2B_ECC_POINT *shared_point = NULL;
+ size_t off = 0;
+ if (Tss2_MU_TPM2B_PRIVATE_Unmarshal(blob, blob_len, &off, &ecdh_priv) !=
+ TSS2_RC_SUCCESS)
+ goto exit;
+ if (Tss2_MU_TPM2B_PUBLIC_Unmarshal(blob, blob_len, &off, &ecdh_pub) !=
+ TSS2_RC_SUCCESS)
+ goto exit;
+
+ TSS2_RC rc = Esys_Load(ctx, primary_handle, ESYS_TR_PASSWORD, ESYS_TR_NONE,
+ ESYS_TR_NONE, &ecdh_priv, &ecdh_pub,
+ &ecdh_key_handle);
+ if (rc != TSS2_RC_SUCCESS) {
+ ret = decode_tpm_rc(rc);
+ goto exit;
+ }
+
+ if (Esys_TR_SetAuth(ctx, ecdh_key_handle, &auth_value) != TSS2_RC_SUCCESS)
+ goto exit;
+
+ TPM2B_ECC_POINT in_point;
+ in_point.point.x.size = 32;
+ in_point.point.y.size = 32;
+ memcpy(in_point.point.x.buffer, pk, 32);
+ memcpy(in_point.point.y.buffer, pk + 32, 32);
+ rc = Esys_ECDH_ZGen(ctx, ecdh_key_handle, ESYS_TR_PASSWORD, ESYS_TR_NONE,
+ ESYS_TR_NONE, &in_point, &shared_point);
+ if (rc != TSS2_RC_SUCCESS) {
+ ret = decode_tpm_rc(rc);
+ goto exit;
+ }
+
+ // Deliver the x coordinate of the shared point.
+ memcpy(ss, shared_point->point.x.buffer, 32);
+
+ ret = STKB_SUCCESS;
+
+exit:
+ Esys_Free(shared_point);
+ if (primary_handle != ESYS_TR_NONE)
+ Esys_FlushContext(ctx, primary_handle);
+ if (ecdh_key_handle != ESYS_TR_NONE)
+ Esys_FlushContext(ctx, ecdh_key_handle);
+ Esys_Finalize(&ctx);
+ return ret;
+}