diff --git a/contrib/win32/openssh/ssh-agent.vcxproj b/contrib/win32/openssh/ssh-agent.vcxproj
index cc6e9b5e4813..8a5b1e6e13e3 100644
--- a/contrib/win32/openssh/ssh-agent.vcxproj
+++ b/contrib/win32/openssh/ssh-agent.vcxproj
@@ -416,12 +416,14 @@
+
+
diff --git a/contrib/win32/openssh/unittest-win32compat.vcxproj b/contrib/win32/openssh/unittest-win32compat.vcxproj
index bbc3ed9b73f4..b52de4bfd072 100644
--- a/contrib/win32/openssh/unittest-win32compat.vcxproj
+++ b/contrib/win32/openssh/unittest-win32compat.vcxproj
@@ -36,6 +36,15 @@
+
+ true
+
+
+ true
+
+
+ true
+
true
@@ -65,6 +74,7 @@
+
diff --git a/contrib/win32/win32compat/pkcs11-cert.c b/contrib/win32/win32compat/pkcs11-cert.c
new file mode 100644
index 000000000000..ff789e7b96b8
--- /dev/null
+++ b/contrib/win32/win32compat/pkcs11-cert.c
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include "includes.h"
+
+#include "authfd.h"
+#include "digest.h"
+#include "log.h"
+#include "sshbuf.h"
+#include "ssherr.h"
+#include "sshkey.h"
+#include "xmalloc.h"
+
+#include "pkcs11-cert.h"
+
+char *
+pkcs11_identity_name(const struct sshkey *key, const u_char *blob,
+ size_t blob_len)
+{
+ u_char digest[SSH_DIGEST_MAX_LENGTH];
+ char *name;
+ size_t i, digest_len;
+
+ if (!sshkey_is_cert(key))
+ return sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT,
+ SSH_FP_DEFAULT);
+ digest_len = ssh_digest_bytes(SSH_DIGEST_SHA256);
+ if (ssh_digest_memory(SSH_DIGEST_SHA256, blob, blob_len, digest,
+ sizeof(digest)) != 0)
+ return NULL;
+ name = xmalloc(5 + digest_len * 2 + 1);
+ memcpy(name, "cert-", 5);
+ for (i = 0; i < digest_len; i++)
+ snprintf(name + 5 + i * 2, 3, "%02x", digest[i]);
+ return name;
+}
+
+const char *
+pkcs11_identity_comment(const char *provider, const char *label)
+{
+ return label == NULL || *label == '\0' ? provider : label;
+}
+
+void
+free_pkcs11_certs(struct sshkey **certs, size_t ncerts)
+{
+ size_t i;
+
+ for (i = 0; i < ncerts; i++)
+ sshkey_free(certs[i]);
+ free(certs);
+}
+
+int
+parse_pkcs11_add_constraints(struct sshbuf *m, int *cert_onlyp,
+ struct sshkey ***certsp, size_t *ncertsp)
+{
+ struct sshbuf *b = NULL;
+ struct sshkey *key = NULL;
+ char *ext_name = NULL;
+ u_char ctype, value;
+ u_int seconds;
+ int r, seen = 0, seen_lifetime = 0, seen_confirm = 0;
+ int seen_destinations = 0;
+
+ if (m == NULL || cert_onlyp == NULL || certsp == NULL ||
+ ncertsp == NULL || *certsp != NULL || *ncertsp != 0)
+ return SSH_ERR_INVALID_ARGUMENT;
+ *cert_onlyp = 0;
+
+ while (sshbuf_len(m) != 0) {
+ if ((r = sshbuf_get_u8(m, &ctype)) != 0)
+ goto out;
+ if (ctype == SSH_AGENT_CONSTRAIN_LIFETIME) {
+ if (seen_lifetime ||
+ (r = sshbuf_get_u32(m, &seconds)) != 0) {
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ seen_lifetime = 1;
+ continue;
+ }
+ if (ctype == SSH_AGENT_CONSTRAIN_CONFIRM) {
+ if (seen_confirm) {
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ seen_confirm = 1;
+ continue;
+ }
+ if (ctype != SSH_AGENT_CONSTRAIN_EXTENSION) {
+ error_f("unsupported smartcard constraint %u", ctype);
+ r = SSH_ERR_FEATURE_UNSUPPORTED;
+ goto out;
+ }
+ if ((r = sshbuf_get_cstring(m, &ext_name, NULL)) != 0)
+ goto out;
+ if (strcmp(ext_name,
+ "restrict-destination-v00@openssh.com") == 0) {
+ if (seen_destinations || sshbuf_skip_string(m) != 0) {
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ seen_destinations = 1;
+ free(ext_name);
+ ext_name = NULL;
+ continue;
+ }
+ if (strcmp(ext_name,
+ "associated-certs-v00@openssh.com") != 0) {
+ error_f("unsupported smartcard constraint \"%s\"",
+ ext_name);
+ r = SSH_ERR_FEATURE_UNSUPPORTED;
+ goto out;
+ }
+ if (seen) {
+ error_f("%s already set", ext_name);
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ seen = 1;
+ if ((r = sshbuf_get_u8(m, &value)) != 0 ||
+ (r = sshbuf_froms(m, &b)) != 0)
+ goto out;
+ *cert_onlyp = value != 0;
+ while (sshbuf_len(b) != 0) {
+ if (*ncertsp >= AGENT_MAX_EXT_CERTS) {
+ error_f("too many %s constraints", ext_name);
+ r = SSH_ERR_INVALID_FORMAT;
+ goto out;
+ }
+ if ((r = sshkey_froms(b, &key)) != 0)
+ goto out;
+ *certsp = xrecallocarray(*certsp, *ncertsp,
+ *ncertsp + 1, sizeof(**certsp));
+ (*certsp)[(*ncertsp)++] = key;
+ key = NULL;
+ }
+ sshbuf_free(b);
+ b = NULL;
+ free(ext_name);
+ ext_name = NULL;
+ }
+ r = 0;
+ out:
+ sshkey_free(key);
+ sshbuf_free(b);
+ free(ext_name);
+ return r;
+}
diff --git a/contrib/win32/win32compat/pkcs11-cert.h b/contrib/win32/win32compat/pkcs11-cert.h
new file mode 100644
index 000000000000..c6019672cc61
--- /dev/null
+++ b/contrib/win32/win32compat/pkcs11-cert.h
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#pragma once
+
+#include "sshbuf.h"
+#include "sshkey.h"
+
+#define AGENT_MAX_EXT_CERTS 1024
+
+char *pkcs11_identity_name(const struct sshkey *, const u_char *, size_t);
+const char *pkcs11_identity_comment(const char *, const char *);
+int parse_pkcs11_add_constraints(struct sshbuf *, int *,
+ struct sshkey ***, size_t *);
+void free_pkcs11_certs(struct sshkey **, size_t);
diff --git a/contrib/win32/win32compat/ssh-agent/keyagent-request.c b/contrib/win32/win32compat/ssh-agent/keyagent-request.c
index b9b4b036e19b..4e3088b16371 100644
--- a/contrib/win32/win32compat/ssh-agent/keyagent-request.c
+++ b/contrib/win32/win32compat/ssh-agent/keyagent-request.c
@@ -36,6 +36,7 @@
#include
#ifdef ENABLE_PKCS11
#include "ssh-pkcs11.h"
+#include "pkcs11-cert.h"
#endif
#include "xmalloc.h"
@@ -365,6 +366,470 @@ process_add_identity(struct sshbuf* request, struct sshbuf* response, struct age
return r;
}
+#ifdef ENABLE_PKCS11
+struct pkcs11_identity_change {
+ char *name;
+ int created;
+ int had_provider;
+ DWORD provider_type;
+ u_char *provider;
+ DWORD provider_len;
+ int had_comment;
+ DWORD comment_type;
+ u_char *comment;
+ DWORD comment_len;
+};
+
+static void
+free_pkcs11_identity_change(struct pkcs11_identity_change *change)
+{
+ if (change == NULL)
+ return;
+ free(change->name);
+ free(change->provider);
+ free(change->comment);
+ free(change);
+}
+
+static int
+read_optional_reg_value(HKEY key, const wchar_t *name, int *presentp,
+ DWORD *typep, u_char **datap, DWORD *lenp)
+{
+ LSTATUS status;
+
+ *presentp = 0;
+ *datap = NULL;
+ *lenp = 0;
+ status = RegQueryValueExW(key, name, NULL, typep, NULL, lenp);
+ if (status == ERROR_FILE_NOT_FOUND)
+ return 0;
+ if (status != ERROR_SUCCESS || *lenp > MAX_MESSAGE_SIZE)
+ return -1;
+ *datap = xmalloc(*lenp == 0 ? 1 : *lenp);
+ if (RegQueryValueExW(key, name, NULL, typep, *datap,
+ lenp) != ERROR_SUCCESS) {
+ free(*datap);
+ *datap = NULL;
+ return -1;
+ }
+ *presentp = 1;
+ return 0;
+}
+
+static int
+restore_optional_reg_value(HKEY key, const wchar_t *name, int present,
+ DWORD type, const u_char *data, DWORD len)
+{
+ LSTATUS status;
+
+ if (present)
+ return RegSetValueExW(key, name, 0, type, data, len) ==
+ ERROR_SUCCESS ? 0 : -1;
+ status = RegDeleteValueW(key, name);
+ return status == ERROR_SUCCESS || status == ERROR_FILE_NOT_FOUND ? 0 : -1;
+}
+
+static int
+restore_pkcs11_identity_metadata(HKEY key,
+ const struct pkcs11_identity_change *change)
+{
+ int r1, r2;
+
+ r1 = restore_optional_reg_value(key, L"provider",
+ change->had_provider, change->provider_type, change->provider,
+ change->provider_len);
+ r2 = restore_optional_reg_value(key, L"comment", change->had_comment,
+ change->comment_type, change->comment, change->comment_len);
+ return r1 == 0 && r2 == 0 ? 0 : -1;
+}
+
+static int
+store_pkcs11_identity(HKEY user_root, const struct sshkey *key,
+ const char *provider, const char *comment,
+ struct pkcs11_identity_change **changep)
+{
+ SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ HKEY reg = NULL, sub = NULL;
+ u_char *blob = NULL;
+ size_t blob_len;
+ char *thumbprint = NULL;
+ struct pkcs11_identity_change *change = NULL;
+ DWORD disposition = 0;
+ ULONG sd_len = 0;
+ int success = 0;
+
+ if (changep == NULL || provider == NULL || comment == NULL)
+ return -1;
+ *changep = NULL;
+ sa.nLength = sizeof(sa);
+ if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL,
+ SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sd_len) ||
+ sshkey_to_blob(key, &blob, &blob_len) != 0 ||
+ blob_len == 0 || blob_len > MAX_MESSAGE_SIZE ||
+ (thumbprint = pkcs11_identity_name(key, blob, blob_len)) == NULL ||
+ RegCreateKeyExW(user_root, SSH_KEYS_ROOT, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != ERROR_SUCCESS ||
+ RegCreateKeyExA(reg, thumbprint, 0, NULL, 0,
+ KEY_WRITE | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sa, &sub,
+ &disposition) != ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 identity");
+ goto out;
+ }
+ change = xcalloc(1, sizeof(*change));
+ change->name = xstrdup(thumbprint);
+ if (disposition == REG_OPENED_EXISTING_KEY) {
+ if (read_optional_reg_value(sub, L"provider",
+ &change->had_provider, &change->provider_type,
+ &change->provider, &change->provider_len) != 0 ||
+ read_optional_reg_value(sub, L"comment",
+ &change->had_comment, &change->comment_type,
+ &change->comment, &change->comment_len) != 0) {
+ error_f("failed to read PKCS11 identity metadata");
+ goto out;
+ }
+ if (RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) !=
+ ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"comment", 0, REG_BINARY,
+ (const BYTE *)comment, (DWORD)strlen(comment)) !=
+ ERROR_SUCCESS) {
+ error_f("failed to update PKCS11 identity metadata");
+ if (restore_pkcs11_identity_metadata(sub, change) != 0)
+ error_f("failed to restore PKCS11 identity metadata");
+ goto out;
+ }
+ } else {
+ change->created = 1;
+ if (RegSetValueExW(sub, NULL, 0, REG_BINARY, blob,
+ (DWORD)blob_len) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"pub", 0, REG_BINARY, blob,
+ (DWORD)blob_len) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"type", 0, REG_DWORD,
+ (const BYTE *)&key->type, sizeof(key->type)) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) !=
+ ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"comment", 0, REG_BINARY,
+ (const BYTE *)comment, (DWORD)strlen(comment)) !=
+ ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 identity");
+ goto out;
+ }
+ }
+ *changep = change;
+ change = NULL;
+ success = 1;
+ out:
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if (!success && disposition == REG_CREATED_NEW_KEY && reg != NULL &&
+ thumbprint != NULL)
+ RegDeleteTreeA(reg, thumbprint);
+ if (reg != NULL)
+ RegCloseKey(reg);
+ if (sa.lpSecurityDescriptor != NULL)
+ LocalFree(sa.lpSecurityDescriptor);
+ free_pkcs11_identity_change(change);
+ free(thumbprint);
+ free(blob);
+ return success ? 0 : -1;
+}
+
+static int
+store_pkcs11_provider(HKEY user_root, struct agent_connection *con,
+ const char *provider, const char *pin, size_t pin_len)
+{
+ SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ HKEY reg = NULL, sub = NULL;
+ char *epin = NULL;
+ DWORD epin_len = 0;
+ DWORD disposition = 0;
+ ULONG sd_len = 0;
+ int success = 0;
+
+ sa.nLength = sizeof(sa);
+ if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL,
+ SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sd_len) ||
+ convert_blob(con, pin, (DWORD)pin_len, &epin, &epin_len, TRUE) != 0 ||
+ RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != ERROR_SUCCESS ||
+ RegCreateKeyExA(reg, provider, 0, NULL, 0,
+ KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub,
+ &disposition) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"provider", 0, REG_BINARY,
+ (const BYTE *)provider, (DWORD)strlen(provider)) != ERROR_SUCCESS ||
+ RegSetValueExW(sub, L"pin", 0, REG_BINARY, (const BYTE *)epin,
+ epin_len) != ERROR_SUCCESS) {
+ error_f("failed to persist PKCS11 provider");
+ goto out;
+ }
+ success = 1;
+ out:
+ if (epin != NULL) {
+ SecureZeroMemory(epin, epin_len);
+ free(epin);
+ }
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ if (!success && disposition == REG_CREATED_NEW_KEY && reg != NULL)
+ RegDeleteTreeA(reg, provider);
+ if (reg != NULL)
+ RegCloseKey(reg);
+ if (sa.lpSecurityDescriptor != NULL)
+ LocalFree(sa.lpSecurityDescriptor);
+ return success ? 0 : -1;
+}
+
+static void
+rollback_pkcs11_identities(HKEY user_root,
+ struct pkcs11_identity_change **changes,
+ size_t nidentities)
+{
+ HKEY reg = NULL, sub = NULL;
+ size_t i;
+
+ if (nidentities == 0)
+ return;
+ if (RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY,
+ ®) != ERROR_SUCCESS) {
+ error_f("failed to open PKCS11 identities for rollback");
+ return;
+ }
+ for (i = nidentities; i > 0; i--) {
+ if (changes[i - 1]->created) {
+ if (RegDeleteTreeA(reg, changes[i - 1]->name) !=
+ ERROR_SUCCESS)
+ error_f("failed to roll back PKCS11 identity");
+ continue;
+ }
+ if (RegOpenKeyExA(reg, changes[i - 1]->name, 0,
+ KEY_SET_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS ||
+ restore_pkcs11_identity_metadata(sub, changes[i - 1]) != 0)
+ error_f("failed to roll back PKCS11 identity metadata");
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ }
+ RegCloseKey(reg);
+}
+
+static int
+remove_pkcs11_identities(HKEY user_root, const char *provider)
+{
+ HKEY root = NULL, sub = NULL;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len, type, data_len;
+ u_char *data = NULL;
+ size_t provider_len = strlen(provider);
+ int index = 0, present, remove;
+ LSTATUS status;
+
+ status = RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_WOW64_64KEY, &root);
+ if (status == ERROR_FILE_NOT_FOUND)
+ return 0;
+ if (status != ERROR_SUCCESS)
+ return -1;
+ for (;;) {
+ sub_name_len = MAX_KEY_LENGTH;
+ status = RegEnumKeyExW(root, index, sub_name, &sub_name_len,
+ NULL, NULL, NULL, NULL);
+ if (status == ERROR_NO_MORE_ITEMS)
+ break;
+ if (status != ERROR_SUCCESS) {
+ index++;
+ continue;
+ }
+ if (RegOpenKeyExW(root, sub_name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS) {
+ index++;
+ continue;
+ }
+ free(data);
+ data = NULL;
+ if (read_optional_reg_value(sub, L"provider", &present, &type,
+ &data, &data_len) != 0 ||
+ (!present && read_optional_reg_value(sub, L"comment",
+ &present, &type, &data, &data_len) != 0)) {
+ RegCloseKey(sub);
+ sub = NULL;
+ index++;
+ continue;
+ }
+ remove = present && data_len == provider_len &&
+ memcmp(data, provider, provider_len) == 0;
+ RegCloseKey(sub);
+ sub = NULL;
+ if (remove) {
+ if (RegDeleteTreeW(root, sub_name) != ERROR_SUCCESS) {
+ RegCloseKey(root);
+ free(data);
+ return -1;
+ }
+ } else
+ index++;
+ }
+ RegCloseKey(root);
+ free(data);
+ return 0;
+}
+
+static int
+load_pkcs11_identities(HKEY user_root, const char *provider,
+ struct sshkey **token_keys, int nkeys)
+{
+ HKEY root = NULL, sub = NULL;
+ wchar_t sub_name[MAX_KEY_LENGTH];
+ DWORD sub_name_len, blob_len, comment_len, association_len;
+ u_char *blob = NULL;
+ char *comment = NULL, *association = NULL;
+ struct sshkey *registered = NULL, *cert = NULL;
+ u_char *plain_added = NULL;
+ size_t provider_len = strlen(provider);
+ int i, index = 0, legacy, loaded = 0;
+ LSTATUS status;
+
+ if (nkeys > 0)
+ plain_added = xcalloc((size_t)nkeys, sizeof(*plain_added));
+ status = RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
+ KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &root);
+ if (status == ERROR_FILE_NOT_FOUND)
+ goto out;
+ if (status != ERROR_SUCCESS) {
+ error_f("failed to open persisted identities: %ld", status);
+ loaded = -1;
+ goto out;
+ }
+ for (;;) {
+ sub_name_len = MAX_KEY_LENGTH;
+ if (sub != NULL) {
+ RegCloseKey(sub);
+ sub = NULL;
+ }
+ status = RegEnumKeyExW(root, index++, sub_name, &sub_name_len,
+ NULL, NULL, NULL, NULL);
+ if (status == ERROR_NO_MORE_ITEMS)
+ break;
+ if (status != ERROR_SUCCESS)
+ continue;
+ if (RegOpenKeyExW(root, sub_name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"pub", NULL, NULL, NULL,
+ &blob_len) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"comment", NULL, NULL, NULL,
+ &comment_len) != ERROR_SUCCESS ||
+ blob_len == 0 || blob_len > MAX_MESSAGE_SIZE ||
+ comment_len > MAX_MESSAGE_SIZE)
+ continue;
+ status = RegQueryValueExW(sub, L"provider", NULL, NULL, NULL,
+ &association_len);
+ if (status == ERROR_FILE_NOT_FOUND) {
+ legacy = 1;
+ association_len = comment_len;
+ } else if (status == ERROR_SUCCESS &&
+ association_len <= MAX_MESSAGE_SIZE)
+ legacy = 0;
+ else
+ continue;
+ free(blob);
+ free(comment);
+ free(association);
+ blob = xmalloc(blob_len);
+ comment = xmalloc((size_t)comment_len + 1);
+ association = xmalloc((size_t)association_len + 1);
+ if (RegQueryValueExW(sub, L"pub", NULL, NULL, blob,
+ &blob_len) != ERROR_SUCCESS ||
+ RegQueryValueExW(sub, L"comment", NULL, NULL,
+ (BYTE *)comment, &comment_len) != ERROR_SUCCESS ||
+ (!legacy && RegQueryValueExW(sub, L"provider", NULL, NULL,
+ (BYTE *)association, &association_len) != ERROR_SUCCESS))
+ continue;
+ comment[comment_len] = '\0';
+ if (legacy)
+ memcpy(association, comment, comment_len);
+ association[association_len] = '\0';
+ if (association_len != provider_len ||
+ memcmp(association, provider, provider_len) != 0)
+ continue;
+ sshkey_free(registered);
+ registered = NULL;
+ if (sshkey_from_blob(blob, blob_len, ®istered) != 0)
+ continue;
+ for (i = 0; i < nkeys; i++) {
+ if (token_keys[i] == NULL)
+ continue;
+ if (sshkey_is_cert(registered)) {
+ if (!sshkey_equal_public(token_keys[i], registered))
+ continue;
+ if (pkcs11_make_cert(token_keys[i], registered,
+ &cert) != 0)
+ continue;
+ add_key(cert, (char *)provider);
+ cert = NULL;
+ loaded++;
+ break;
+ }
+ if (!plain_added[i] &&
+ sshkey_equal(token_keys[i], registered)) {
+ plain_added[i] = 1;
+ break;
+ }
+ }
+ }
+ for (i = 0; i < nkeys; i++) {
+ if (!plain_added[i] || token_keys[i] == NULL)
+ continue;
+ add_key(token_keys[i], (char *)provider);
+ token_keys[i] = NULL;
+ loaded++;
+ }
+ out:
+ sshkey_free(cert);
+ sshkey_free(registered);
+ free(plain_added);
+ free(association);
+ free(comment);
+ free(blob);
+ if (sub != NULL)
+ RegCloseKey(sub);
+ if (root != NULL)
+ RegCloseKey(root);
+ return loaded;
+}
+
+static void
+free_pkcs11_sign_provider(char **providerp, char **pinp, DWORD pin_len,
+ char **epinp, DWORD epin_len, struct sshkey ***keysp, int nkeys)
+{
+ int i;
+
+ if (*keysp != NULL) {
+ for (i = 0; i < nkeys; i++)
+ sshkey_free((*keysp)[i]);
+ free(*keysp);
+ *keysp = NULL;
+ }
+ free(*providerp);
+ *providerp = NULL;
+ if (*pinp != NULL) {
+ SecureZeroMemory(*pinp, pin_len);
+ free(*pinp);
+ *pinp = NULL;
+ }
+ if (*epinp != NULL) {
+ SecureZeroMemory(*epinp, epin_len);
+ free(*epinp);
+ *epinp = NULL;
+ }
+}
+#endif /* ENABLE_PKCS11 */
+
static int sign_blob(const struct sshkey *pubkey, u_char ** sig, size_t *siglen,
const u_char *blob, size_t blen, u_int flags, struct agent_connection* con)
{
@@ -454,10 +919,11 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
struct sshkey *key = NULL;
#ifdef ENABLE_PKCS11
- int i, count = 0, index = 0;;
+ int count = 0, index = 0, loaded = 0;
wchar_t sub_name[MAX_KEY_LENGTH];
DWORD sub_name_len = MAX_KEY_LENGTH;
- DWORD pin_len, epin_len, provider_len;
+ DWORD pin_len = 0, epin_len = 0, provider_len = 0;
+ DWORD epin_alloc_len = 0;
char *pin = NULL, *npin = NULL, *epin = NULL, *provider = NULL;
HKEY root = 0, sub = 0, user_root = 0;
struct sshkey **keys = NULL;
@@ -475,6 +941,8 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
while (1) {
sub_name_len = MAX_KEY_LENGTH;
+ pin_len = epin_len = provider_len = 0;
+ epin_alloc_len = 0;
if (sub) {
RegCloseKey(sub);
sub = NULL;
@@ -483,37 +951,40 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
if (RegOpenKeyExW(root, sub_name, 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub) == 0 &&
RegQueryValueExW(sub, L"provider", 0, NULL, NULL, &provider_len) == 0 &&
RegQueryValueExW(sub, L"pin", 0, NULL, NULL, &epin_len) == 0) {
- if ((epin = malloc(epin_len + 1)) == NULL ||
+ if (provider_len == 0 || provider_len >= PATH_MAX ||
+ epin_len == 0 || epin_len > MAX_MESSAGE_SIZE)
+ continue;
+ epin_alloc_len = epin_len;
+ if ((epin = malloc(epin_alloc_len + 1)) == NULL ||
(provider = malloc(provider_len + 1)) == NULL ||
RegQueryValueExW(sub, L"provider", 0, NULL, provider, &provider_len) != 0 ||
- RegQueryValueExW(sub, L"pin", 0, NULL, epin, &epin_len) != 0)
- goto done;
+ RegQueryValueExW(sub, L"pin", 0, NULL, epin, &epin_len) != 0) {
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
+ }
provider[provider_len] = '\0';
epin[epin_len] = '\0';
if (convert_blob(con, epin, epin_len, &pin, &pin_len, 0) != 0 ||
(npin = realloc(pin, pin_len + 1)) == NULL) {
- goto done;
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
}
pin = npin;
pin[pin_len] = '\0';
count = pkcs11_add_provider(provider, pin, &keys, NULL);
- for (i = 0; i < count; i++) {
- add_key(keys[i], provider);
- }
- free(keys);
- if (provider)
- free(provider);
- if (pin) {
- SecureZeroMemory(pin, (DWORD)pin_len);
- free(pin);
+ if (count <= 0) {
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ continue;
}
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
- provider = NULL;
- pin = NULL;
- epin = NULL;
+ loaded = load_pkcs11_identities(user_root, provider,
+ keys, count);
+ free_pkcs11_sign_provider(&provider, &pin, pin_len,
+ &epin, epin_alloc_len, &keys, count);
+ if (loaded < 0)
+ goto done;
}
}
else
@@ -553,18 +1024,10 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
if (signature)
free(signature);
#ifdef ENABLE_PKCS11
+ free_pkcs11_sign_provider(&provider, &pin, pin_len, &epin, epin_alloc_len,
+ &keys, count);
del_all_keys();
pkcs11_terminate();
- if (provider)
- free(provider);
- if (pin) {
- SecureZeroMemory(pin, (DWORD)pin_len);
- free(pin);
- }
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
if (user_root)
RegCloseKey(user_root);
if (root)
@@ -575,14 +1038,59 @@ process_sign_request(struct sshbuf* request, struct sshbuf* response, struct age
return r;
}
+static LSTATUS
+delete_matching_identity(HKEY root, const char *name, const u_char *blob,
+ size_t blob_len)
+{
+ HKEY sub = NULL;
+ u_char *stored_blob = NULL;
+ DWORD stored_blob_len = 0;
+ LSTATUS status;
+
+ status = RegOpenKeyExA(root, name, 0,
+ KEY_QUERY_VALUE | KEY_WOW64_64KEY, &sub);
+ if (status != ERROR_SUCCESS)
+ return status;
+ status = RegQueryValueExW(sub, L"pub", NULL, NULL, NULL,
+ &stored_blob_len);
+ if (status != ERROR_SUCCESS)
+ goto out;
+ if (stored_blob_len > MAX_MESSAGE_SIZE) {
+ status = ERROR_INVALID_DATA;
+ goto out;
+ }
+ stored_blob = xmalloc(stored_blob_len == 0 ? 1 : stored_blob_len);
+ status = RegQueryValueExW(sub, L"pub", NULL, NULL, stored_blob,
+ &stored_blob_len);
+ if (status != ERROR_SUCCESS)
+ goto out;
+ if (stored_blob_len != blob_len ||
+ memcmp(stored_blob, blob, blob_len) != 0) {
+ status = ERROR_FILE_NOT_FOUND;
+ goto out;
+ }
+ RegCloseKey(sub);
+ sub = NULL;
+ status = RegDeleteTreeA(root, name);
+ out:
+ free(stored_blob);
+ if (sub != NULL)
+ RegCloseKey(sub);
+ return status;
+}
+
int
process_remove_key(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
{
HKEY user_root = 0, root = 0;
char *blob, *thumbprint = NULL;
+#ifdef ENABLE_PKCS11
+ char *pkcs11_name = NULL;
+#endif
size_t blen;
int r = 0, success = 0, request_invalid = 0;
struct sshkey *key = NULL;
+ LSTATUS status;
if (sshbuf_get_string_direct(request, &blob, &blen) != 0 ||
sshkey_from_blob(blob, blen, &key) != 0) {
@@ -590,11 +1098,26 @@ process_remove_key(struct sshbuf* request, struct sshbuf* response, struct agent
goto done;
}
- if ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT, SSH_FP_DEFAULT)) == NULL ||
+ if ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT,
+ SSH_FP_DEFAULT)) == NULL ||
get_user_root(con, &user_root) != 0 ||
RegOpenKeyExW(user_root, SSH_KEYS_ROOT, 0,
- DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE | KEY_WOW64_64KEY, &root) != 0 ||
- RegDeleteTreeA(root, thumbprint) != 0)
+ DELETE | KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE |
+ KEY_WOW64_64KEY, &root) != 0)
+ goto done;
+ status = delete_matching_identity(root, thumbprint,
+ (const u_char *)blob, blen);
+#ifdef ENABLE_PKCS11
+ if (status == ERROR_FILE_NOT_FOUND && sshkey_is_cert(key)) {
+ pkcs11_name = pkcs11_identity_name(key,
+ (const u_char *)blob, blen);
+ if (pkcs11_name == NULL)
+ goto done;
+ status = delete_matching_identity(root, pkcs11_name,
+ (const u_char *)blob, blen);
+ }
+#endif
+ if (status != ERROR_SUCCESS)
goto done;
success = 1;
done:
@@ -612,6 +1135,9 @@ process_remove_key(struct sshbuf* request, struct sshbuf* response, struct agent
RegCloseKey(root);
if (thumbprint)
free(thumbprint);
+#ifdef ENABLE_PKCS11
+ free(pkcs11_name);
+#endif
return r;
}
int
@@ -641,28 +1167,37 @@ process_remove_all(struct sshbuf* request, struct sshbuf* response, struct agent
}
#ifdef ENABLE_PKCS11
-int process_add_smartcard_key(struct sshbuf* request, struct sshbuf* response, struct agent_connection* con)
+int
+process_add_smartcard_key(struct sshbuf *request, struct sshbuf *response,
+ struct agent_connection *con)
{
- char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX];
- int i, count = 0, r = 0, request_invalid = 0, success = 0;
- struct sshkey **keys = NULL;
- struct sshkey* key = NULL;
- size_t pubkey_blob_len, provider_len, pin_len, epin_len;
- u_char *pubkey_blob = NULL;
- char *thumbprint = NULL;
- char *epin = NULL;
- HKEY reg = 0, sub = 0, user_root = 0;
- SECURITY_ATTRIBUTES sa = { 0, NULL, 0 };
+ char *provider = NULL, *pin = NULL, canonical_provider[PATH_MAX] = { 0 };
+ char allowed_provider[PATH_MAX], **labels = NULL;
+ const char *comment;
+ int i, j, count = 0, r = 0, request_invalid = 0, success = 0;
+ int cert_only = 0, identities_stored = 0;
+ struct sshkey **keys = NULL, **certs = NULL, *cert = NULL;
+ struct pkcs11_identity_change *identity_change = NULL;
+ struct pkcs11_identity_change **identity_changes = NULL;
+ size_t k, pin_len = 0, ncerts = 0, nidentity_changes = 0;
+ HKEY user_root = NULL;
pkcs11_init(0);
- if ((r = sshbuf_get_cstring(request, &provider, &provider_len)) != 0 ||
- (r = sshbuf_get_cstring(request, &pin, &pin_len)) != 0 ||
- pin_len > 256) {
+ if ((r = sshbuf_get_cstring(request, &provider, NULL)) != 0 ||
+ (r = sshbuf_get_cstring(request, &pin, &pin_len)) != 0 ||
+ pin_len > 256) {
error("add smartcard request is invalid");
request_invalid = 1;
goto done;
}
+ if (sshbuf_len(request) != 0 &&
+ parse_pkcs11_add_constraints(request, &cert_only, &certs,
+ &ncerts) != 0) {
+ error("add smartcard constraints are invalid");
+ request_invalid = 1;
+ goto done;
+ }
if (con->nsession_ids != 0 && !remote_add_provider) {
verbose("failed PKCS#11 add of \"%.100s\": remote addition of "
@@ -677,76 +1212,69 @@ int process_add_smartcard_key(struct sshbuf* request, struct sshbuf* response, s
goto done;
}
- to_lower_case(provider);
- verbose("provider realpath: \"%.100s\"", provider);
+ /* Remove the leading slash from the canonical Windows drive path. */
+ if (canonical_provider[0] == '/')
+ memmove(canonical_provider, canonical_provider + 1,
+ strlen(canonical_provider));
+ strcpy_s(allowed_provider, sizeof(allowed_provider), canonical_provider);
+ for (i = 0; allowed_provider[i] != '\0'; i++) {
+ if (allowed_provider[i] == '/')
+ allowed_provider[i] = '\\';
+ }
+ to_lower_case(allowed_provider);
+ verbose("provider realpath: \"%.100s\"", canonical_provider);
verbose("allowed provider paths: \"%.100s\"", allowed_providers);
- if (match_pattern_list(provider, allowed_providers, 1) != 1) {
+ if (match_pattern_list(allowed_provider, allowed_providers, 1) != 1) {
verbose("refusing PKCS#11 add of \"%.100s\": "
- "provider not allowed", provider);
+ "provider not allowed", canonical_provider);
goto done;
}
- // Remove 'drive root' if exists
- if (canonical_provider[0] == '/')
- memmove(canonical_provider, canonical_provider + 1, strlen(canonical_provider));
-
- count = pkcs11_add_provider(canonical_provider, pin, &keys, NULL);
+ count = pkcs11_add_provider(canonical_provider, pin, &keys, &labels);
if (count <= 0) {
- error_f("failed to add key to store. count:%d", count);
+ error_f("failed to load provider keys: count:%d", count);
goto done;
}
- // If HKCU registry already has the provider then remove the provider and associated keys.
- // This allows customers to add new keys.
- if (get_user_root(con, &user_root) != 0 ||
- is_reg_sub_key_exists(user_root, SSH_PKCS11_PROVIDERS_ROOT, canonical_provider)) {
- remove_matching_subkeys_from_registry(user_root, SSH_KEYS_ROOT, L"comment", canonical_provider);
- remove_matching_subkeys_from_registry(user_root, SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider);
- }
+ if (get_user_root(con, &user_root) != 0)
+ goto done;
for (i = 0; i < count; i++) {
- key = keys[i];
- if (sa.lpSecurityDescriptor)
- LocalFree(sa.lpSecurityDescriptor);
- if (reg) {
- RegCloseKey(reg);
- reg = NULL;
- }
- if (sub) {
- RegCloseKey(sub);
- sub = NULL;
+ comment = pkcs11_identity_comment(canonical_provider, labels[i]);
+ for (j = 0; j < (int)ncerts; j++) {
+ if (!sshkey_is_cert(certs[j]) ||
+ !sshkey_equal_public(keys[i], certs[j]))
+ continue;
+ if (pkcs11_make_cert(keys[i], certs[j], &cert) != 0)
+ continue;
+ if (store_pkcs11_identity(user_root, cert,
+ canonical_provider, comment, &identity_change) != 0)
+ goto done;
+ identity_changes = xrecallocarray(identity_changes,
+ nidentity_changes, nidentity_changes + 1,
+ sizeof(*identity_changes));
+ identity_changes[nidentity_changes++] = identity_change;
+ identity_change = NULL;
+ sshkey_free(cert);
+ cert = NULL;
+ identities_stored++;
}
- memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
- sa.nLength = sizeof(sa);
- if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sa.nLength)) ||
- sshkey_to_blob(key, &pubkey_blob, &pubkey_blob_len) != 0 ||
- ((thumbprint = sshkey_fingerprint(key, SSH_FP_HASH_DEFAULT, SSH_FP_DEFAULT)) == NULL) ||
- RegCreateKeyExW(user_root, SSH_KEYS_ROOT, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != 0 ||
- RegCreateKeyExA(reg, thumbprint, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub, NULL) != 0 ||
- RegSetValueExW(sub, NULL, 0, REG_BINARY, pubkey_blob, (DWORD)pubkey_blob_len) != 0 ||
- RegSetValueExW(sub, L"pub", 0, REG_BINARY, pubkey_blob, (DWORD)pubkey_blob_len) != 0 ||
- RegSetValueExW(sub, L"type", 0, REG_DWORD, (BYTE*)&key->type, 4) != 0 ||
- RegSetValueExW(sub, L"comment", 0, REG_BINARY, canonical_provider, (DWORD)strlen(canonical_provider)) != 0) {
- error_f("failed to add key to store");
+ if (!cert_only && store_pkcs11_identity(user_root, keys[i],
+ canonical_provider, comment, &identity_change) == 0) {
+ identity_changes = xrecallocarray(identity_changes,
+ nidentity_changes, nidentity_changes + 1,
+ sizeof(*identity_changes));
+ identity_changes[nidentity_changes++] = identity_change;
+ identity_change = NULL;
+ identities_stored++;
+ } else if (!cert_only)
goto done;
- }
}
- debug("added smartcard keys to store");
-
- memset(&sa, 0, sizeof(SECURITY_ATTRIBUTES));
- sa.nLength = sizeof(sa);
- if ((!ConvertStringSecurityDescriptorToSecurityDescriptorW(REG_KEY_SDDL, SDDL_REVISION_1, &sa.lpSecurityDescriptor, &sa.nLength)) ||
- convert_blob(con, pin, (DWORD)pin_len, &epin, (DWORD*)&epin_len, 1) != 0 ||
- RegCreateKeyExW(user_root, SSH_PKCS11_PROVIDERS_ROOT, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, ®, NULL) != 0 ||
- RegCreateKeyExA(reg, canonical_provider, 0, 0, 0, KEY_WRITE | KEY_WOW64_64KEY, &sa, &sub, NULL) != 0 ||
- RegSetValueExW(sub, L"provider", 0, REG_BINARY, canonical_provider, (DWORD)strlen(canonical_provider)) != 0 ||
- RegSetValueExW(sub, L"pin", 0, REG_BINARY, epin, (DWORD)epin_len) != 0) {
- error("failed to add pkcs11 provider to store");
+ if (identities_stored == 0 || store_pkcs11_provider(user_root, con,
+ canonical_provider, pin, pin_len) != 0)
goto done;
- }
-
- debug("added pkcs11 provider to store");
+ debug("added PKCS11 provider and identities to store");
success = 1;
done:
r = 0;
@@ -755,44 +1283,31 @@ int process_add_smartcard_key(struct sshbuf* request, struct sshbuf* response, s
else if (sshbuf_put_u8(response, success ? SSH_AGENT_SUCCESS : SSH_AGENT_FAILURE) != 0)
r = -1;
- /* delete created reg keys if not succeeded*/
- if ((success == 0) && reg) {
- if (thumbprint)
- RegDeleteKeyExA(reg, thumbprint, KEY_WOW64_64KEY, 0);
- if (canonical_provider)
- RegDeleteKeyExA(reg, canonical_provider, KEY_WOW64_64KEY, 0);
- }
+ if (!success && user_root != NULL)
+ rollback_pkcs11_identities(user_root, identity_changes,
+ nidentity_changes);
pkcs11_terminate();
- if (sa.lpSecurityDescriptor)
- LocalFree(sa.lpSecurityDescriptor);
+ sshkey_free(cert);
+ free_pkcs11_identity_change(identity_change);
+ for (k = 0; k < nidentity_changes; k++)
+ free_pkcs11_identity_change(identity_changes[k]);
+ free(identity_changes);
for (i = 0; i < count; i++)
sshkey_free(keys[i]);
- if (keys)
- free(keys);
- if (thumbprint)
- free(thumbprint);
- if (pubkey_blob)
- free(pubkey_blob);
- if (provider)
- free(provider);
- if (allowed_providers)
- free(allowed_providers);
+ free(keys);
+ for (i = 0; i < count; i++)
+ free(labels[i]);
+ free(labels);
+ free_pkcs11_certs(certs, ncerts);
+ free(provider);
if (pin) {
SecureZeroMemory(pin, (DWORD)pin_len);
free(pin);
}
- if (epin) {
- SecureZeroMemory(epin, (DWORD)epin_len);
- free(epin);
- }
if (user_root)
RegCloseKey(user_root);
- if (reg)
- RegCloseKey(reg);
- if (sub)
- RegCloseKey(sub);
return r;
}
@@ -824,8 +1339,9 @@ int process_remove_smartcard_key(struct sshbuf* request, struct sshbuf* response
!is_reg_sub_key_exists(user_root, SSH_PKCS11_PROVIDERS_ROOT, canonical_provider))
goto done;
- if (remove_matching_subkeys_from_registry(user_root, SSH_KEYS_ROOT, L"comment", canonical_provider) != 0 ||
- remove_matching_subkeys_from_registry(user_root, SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider) != 0) {
+ if (remove_pkcs11_identities(user_root, canonical_provider) != 0 ||
+ remove_matching_subkeys_from_registry(user_root,
+ SSH_PKCS11_PROVIDERS_ROOT, L"provider", canonical_provider) != 0) {
goto done;
}
diff --git a/regress/pesterTests/CommonUtils.psm1 b/regress/pesterTests/CommonUtils.psm1
index 67c696e99dbf..a4222185025a 100644
--- a/regress/pesterTests/CommonUtils.psm1
+++ b/regress/pesterTests/CommonUtils.psm1
@@ -67,7 +67,7 @@ function Set-FilePermission
function Add-PasswordSetting
{
param([string] $pass)
- if ($IsWindows) {
+ if ($IsWindows -or $env:OS -eq "Windows_NT") {
if (-not($env:DISPLAY)) {$env:DISPLAY = 1}
$askpass_util = Join-Path $PSScriptRoot "utilities\askpass_util\askpass_util.exe"
$env:SSH_ASKPASS=$askpass_util
diff --git a/regress/pesterTests/KeyUtils.Tests.ps1 b/regress/pesterTests/KeyUtils.Tests.ps1
index 5881f509d57f..14dc13fb24d9 100644
--- a/regress/pesterTests/KeyUtils.Tests.ps1
+++ b/regress/pesterTests/KeyUtils.Tests.ps1
@@ -215,6 +215,7 @@ Describe "E2E scenarios for ssh key management" -Tags "CI" {
}
}
AfterAll{$tC++}
+ AfterEach { Remove-PasswordSetting }
# Executing ssh-agent will start agent service
# This is to support typical Unix scenarios where
@@ -300,16 +301,69 @@ Describe "E2E scenarios for ssh key management" -Tags "CI" {
ValidateRegistryACL -count $allkeys.count
}
+ It "$tC.$tI - ssh-add - remove software certificates" {
+ if ($NoLibreSSL) {
+ Write-Host "skipping software certificate removal test without LibreSSL"
+ return
+ }
+
+ $ca = Join-Path $testDir "software-cert-ca"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ & ssh-keygen -q -t ed25519 -N $keypassphrase -f $ca
+ $LASTEXITCODE | Should Be 0
+
+ try {
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+ Add-PasswordSetting -Pass $keypassphrase
+ $env:SSH_ASKPASS_REQUIRE = "force"
+
+ foreach ($type in @("rsa", "ecdsa")) {
+ $keyPath = Join-Path $testDir "id_$type"
+ & ssh-keygen -q -s $ca -P $keypassphrase `
+ -I "software-$type" -n $env:USERNAME "$keyPath.pub"
+ $LASTEXITCODE | Should Be 0
+ $certPath = "$keyPath-cert.pub"
+
+ cmd /c "ssh-add `"$keyPath`" < `"$nullFile`""
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+
+ $certBlob = (Get-Content $certPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($certBlob) }).Count |
+ Should Be 1
+ & ssh-add -d $certPath
+ $LASTEXITCODE | Should Be 0
+ @((ssh-add -L) | Where-Object { $_.Contains($certBlob) }).Count |
+ Should Be 0
+ }
+ }
+ finally {
+ ssh-add -D | Out-Null
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ foreach ($type in @("rsa", "ecdsa")) {
+ Remove-Item (Join-Path $testDir "id_$type-cert.pub") `
+ -Force -ErrorAction SilentlyContinue
+ }
+ }
+ }
+
It "$tC.$tI - ssh-add - pkcs11 library (if available)" {
- $pkcs11Path = "C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ if (-not $pkcs11Path) {
+ $pkcs11Path = "C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"
+ }
if (Test-Path $pkcs11Path) {
#set up SSH_ASKPASS
- Add-PasswordSetting -Pass $pkcs11Pin
-
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+ if (-not $testPin) { $testPin = $pkcs11Pin }
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
ssh-add -s "$pkcs11Path"
$LASTEXITCODE | Should Be 0
- #remove SSH_ASKPASS
- Remove-PasswordSetting
#ensure added keys are listed
$allkeys = ssh-add -L
@@ -326,6 +380,395 @@ Describe "E2E scenarios for ssh key management" -Tags "CI" {
Write-Host "skipping pkcs11 test because provider not found"
}
}
+
+ It "$tC.$tI - ssh-add - pkcs11 certificates (if configured)" {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+ if (-not $pkcs11Path -or -not (Test-Path $pkcs11Path) -or
+ $publicKeyPaths.Count -eq 0) {
+ Write-Host "skipping pkcs11 certificate test because provider and public keys are not configured"
+ return
+ }
+
+ foreach ($publicKeyPath in $publicKeyPaths) {
+ Test-Path $publicKeyPath | Should Be $true
+ }
+ Test-Path Env:OPENSSH_TEST_PKCS11_LABELS | Should Be $true
+ $pkcs11Labels = @($env:OPENSSH_TEST_PKCS11_LABELS.Split(
+ [char[]]@(';'), [StringSplitOptions]::None))
+ $pkcs11Labels.Count | Should Be $publicKeyPaths.Count
+ $canonicalProvider = [IO.Path]::GetFullPath($pkcs11Path).Replace('\', '/')
+ $expectedComments = @($pkcs11Labels | ForEach-Object {
+ if ($_) { $_ } else { $canonicalProvider }
+ })
+
+ function Assert-Pkcs11IdentityComments {
+ param([string[]]$KeyPaths, [string[]]$Comments)
+
+ $longListing = @(ssh-add -L)
+ $shortListing = @(ssh-add -l)
+ $KeyPaths.Count | Should Be $Comments.Count
+ $fingerprints = @($KeyPaths | ForEach-Object {
+ ((ssh-keygen -lf $_) -split ' ')[1]
+ })
+ for ($index = 0; $index -lt $KeyPaths.Count; $index++) {
+ $keyBlob = (Get-Content $KeyPaths[$index]).Split(' ')[1]
+ $longEntry = @($longListing | Where-Object {
+ $_.Contains($keyBlob)
+ })
+ $longEntry.Count | Should Be 1
+ ($longEntry[0] -split ' ', 3)[2] | Should Be $Comments[$index]
+
+ $fingerprint = $fingerprints[$index]
+ $shortEntry = @($shortListing | Where-Object {
+ $_.Contains(" $fingerprint ")
+ })
+ $shortEntry.Count | Should Be @($fingerprints |
+ Where-Object { $_ -eq $fingerprint }).Count
+ foreach ($entry in $shortEntry) {
+ $entry | Should Match (" " +
+ [regex]::Escape($Comments[$index]) + " \([^)]+\)$")
+ }
+ }
+ }
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+ if (-not $testPin) { $testPin = $pkcs11Pin }
+ $ca = Join-Path $testDir "pkcs11-ca"
+ Remove-Item "$ca*" -Force -ErrorAction SilentlyContinue
+ & ssh-keygen -q -t ed25519 -N $keypassphrase -f $ca
+ $LASTEXITCODE | Should Be 0
+
+ $certPaths = @()
+ $copiedPublicKeyPaths = @()
+ $serial = 1
+ foreach ($publicKeyPath in $publicKeyPaths) {
+ $copiedPublicKeyPath = Join-Path $testDir "pkcs11-$serial.pub"
+ Copy-Item $publicKeyPath $copiedPublicKeyPath -Force
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-$serial" `
+ -n $env:USERNAME -z $serial $copiedPublicKeyPath
+ $LASTEXITCODE | Should Be 0
+ $copiedPublicKeyPaths += $copiedPublicKeyPath
+ $certPaths += $copiedPublicKeyPath.Replace(".pub", "-cert.pub")
+ $serial++
+ }
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-unmatched" `
+ -n $env:USERNAME -z 999 "$ca.pub"
+ $LASTEXITCODE | Should Be 0
+ $unmatchedCertPath = "$ca-cert.pub"
+ $associatedCertPaths = $certPaths + $unmatchedCertPath
+
+ $sequentialPublicKeyPath = Join-Path $testDir "pkcs11-sequential.pub"
+ Copy-Item $publicKeyPaths[0] $sequentialPublicKeyPath -Force
+ & ssh-keygen -q -s $ca -P $keypassphrase -I "pkcs11-sequential" `
+ -n $env:USERNAME -z 1000 $sequentialPublicKeyPath
+ $LASTEXITCODE | Should Be 0
+ $sequentialCertPath = $sequentialPublicKeyPath.Replace(".pub", "-cert.pub")
+
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+
+ $addArguments = @("-s", $pkcs11Path) + $associatedCertPaths
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ & ssh-add -T $keyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths) `
+ ($expectedComments + $expectedComments)
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ foreach ($certPath in $certPaths) {
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths) `
+ ($expectedComments + $expectedComments)
+ & ssh-add -d $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $deletedKeyBlob = (Get-Content $certPaths[0]).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($deletedKeyBlob) }).Count |
+ Should Be 0
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ # Separate additions for the same token key must merge certificates.
+ $addArguments = @("-s", $pkcs11Path, "-C", $certPaths[0])
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $addArguments = @("-s", $pkcs11Path, "-C", $sequentialCertPath)
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($certPath in @($certPaths[0], $sequentialCertPath)) {
+ $keyBlob = (Get-Content $certPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ & ssh-add -T $certPath
+ $LASTEXITCODE | Should Be 0
+ }
+
+ # Re-adding an exact certificate is successful and idempotent.
+ & ssh-add @addArguments
+ $LASTEXITCODE | Should Be 0
+ $sequentialCertBlob = (Get-Content $sequentialCertPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($sequentialCertBlob) }).Count |
+ Should Be 1
+ Assert-Pkcs11IdentityComments `
+ @($certPaths[0], $sequentialCertPath) `
+ @($expectedComments[0], $expectedComments[0])
+
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ # cert-only applies to this request and must preserve plain identities.
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -s $pkcs11Path -C $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $allKeys = @(ssh-add -L)
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths[0]) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @($allKeys | Where-Object { $_.Contains($keyBlob) }).Count | Should Be 1
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths[0]) `
+ ($expectedComments + $expectedComments[0])
+
+ # A failed unmatched add must not change existing persisted identities.
+ $identitiesBefore = @(ssh-add -L | Sort-Object)
+ & ssh-add -s $pkcs11Path -C $unmatchedCertPath
+ $LASTEXITCODE | Should Not Be 0
+ $identitiesAfter = @(ssh-add -L | Sort-Object)
+ @(Compare-Object $identitiesBefore $identitiesAfter).Count | Should Be 0
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ foreach ($keyPath in $copiedPublicKeyPaths + $certPaths[0]) {
+ & ssh-add -T $keyPath
+ $LASTEXITCODE | Should Be 0
+ }
+ Assert-Pkcs11IdentityComments `
+ ($copiedPublicKeyPaths + $certPaths[0]) `
+ ($expectedComments + $expectedComments[0])
+
+ & ssh-add -d $certPaths[0]
+ $LASTEXITCODE | Should Be 0
+ foreach ($keyPath in $copiedPublicKeyPaths) {
+ $keyBlob = (Get-Content $keyPath).Split(' ')[1]
+ @((ssh-add -L) | Where-Object { $_.Contains($keyBlob) }).Count |
+ Should Be 1
+ }
+
+ # Legacy identities used comment for their provider association.
+ $identityRootPath = "$currentUserSid\Software\OpenSSH\Agent\Keys"
+ $identityRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $identityRootPath, $true)
+ $identityRoot | Should Not Be $null
+ $plainBlob = (Get-Content $copiedPublicKeyPaths[0]).Split(' ')[1]
+ $identityKey = $null
+ foreach ($identityName in $identityRoot.GetSubKeyNames()) {
+ $candidate = $identityRoot.OpenSubKey($identityName, $true)
+ $storedBlob = $candidate.GetValue("pub")
+ if ($storedBlob -is [byte[]] -and
+ [Convert]::ToBase64String($storedBlob) -eq $plainBlob) {
+ $identityKey = $candidate
+ break
+ }
+ $candidate.Dispose()
+ }
+ $identityKey | Should Not Be $null
+ $providerBytes = [Text.Encoding]::UTF8.GetBytes($canonicalProvider)
+ $identityKey.DeleteValue("provider", $false)
+ $identityKey.SetValue("comment", $providerBytes,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ Restart-Service ssh-agent
+ WaitForStatus -ServiceName ssh-agent -Status "Running"
+ Assert-Pkcs11IdentityComments @($copiedPublicKeyPaths[0]) `
+ @($canonicalProvider)
+ & ssh-add -T $copiedPublicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ $identityKey.GetValue("provider", $null) | Should Be $null
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("comment")) |
+ Should Be $canonicalProvider
+
+ # A failed provider update must roll legacy metadata back.
+ $providerRootPath = "$currentUserSid\Software\OpenSSH\Agent\PKCS11_Providers"
+ $providerRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $providerRootPath, $true)
+ $providerRoot | Should Not Be $null
+ $providerKey = $providerRoot.OpenSubKey($canonicalProvider,
+ [Microsoft.Win32.RegistryKeyPermissionCheck]::ReadWriteSubTree,
+ [Security.AccessControl.RegistryRights]::FullControl)
+ $providerKey | Should Not Be $null
+ $blockedAcl = $providerKey.GetAccessControl()
+ $denySetValue = New-Object `
+ System.Security.AccessControl.RegistryAccessRule(
+ $systemSid,
+ [System.Security.AccessControl.RegistryRights]::SetValue,
+ [System.Security.AccessControl.AccessControlType]::Deny)
+ $blockedAcl.AddAccessRule($denySetValue) | Out-Null
+ try {
+ $providerKey.SetAccessControl($blockedAcl)
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Not Be 0
+ $identityKey.GetValue("provider", $null) | Should Be $null
+ [Text.Encoding]::UTF8.GetString(
+ $identityKey.GetValue("comment")) |
+ Should Be $canonicalProvider
+ }
+ finally {
+ $blockedAcl.RemoveAccessRuleSpecific($denySetValue)
+ $providerKey.SetAccessControl($blockedAcl)
+ }
+
+ # Re-adding migrates metadata without replacing the key entry.
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("provider")) |
+ Should Be $canonicalProvider
+ [Text.Encoding]::UTF8.GetString($identityKey.GetValue("comment")) |
+ Should Be $expectedComments[0]
+ Assert-Pkcs11IdentityComments @($copiedPublicKeyPaths[0]) `
+ @($expectedComments[0])
+
+ # Provider removal accepts both migrated and legacy identities.
+ $identityKey.DeleteValue("provider", $false)
+ $identityKey.SetValue("comment", $providerBytes,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $providerKey.Dispose()
+ $providerRoot.Dispose()
+ $identityKey.Dispose()
+ $identityRoot.Dispose()
+
+ & ssh-add -e $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ @(ssh-add -L) -match "The agent has no identities." | Should Be $true
+ }
+
+ It "$tC.$tI - ssh-add - stale pkcs11 provider isolation (if configured)" {
+ $pkcs11Path = $env:OPENSSH_TEST_PKCS11_PROVIDER
+ $publicKeyPaths = @($env:OPENSSH_TEST_PKCS11_PUBLIC_KEYS -split ';' |
+ Where-Object { $_ })
+ if (-not $pkcs11Path -or -not (Test-Path $pkcs11Path) -or
+ $publicKeyPaths.Count -eq 0) {
+ Write-Host "skipping stale provider test because provider and public keys are not configured"
+ return
+ }
+
+ $testPin = $env:OPENSSH_TEST_PKCS11_PIN
+ if (-not $testPin) { $testPin = $pkcs11Pin }
+ $softwareKeyPath = Join-Path $testDir "id_rsa"
+ $unavailableKeyPath = Join-Path $testDir "id_ecdsa.pub"
+ $nullFile = Join-Path $testDir "$tC.$tI.nullfile"
+ $null > $nullFile
+ $providerRoot = $null
+ $validProviderKey = $null
+ $staleProviderKey = $null
+ $staleProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-stale-provider.dll"
+ $staleProvider = [IO.Path]::GetFullPath($staleProviderPath).Replace(
+ '\', '/')
+ $corruptProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-corrupt-provider.dll"
+ $corruptProvider = [IO.Path]::GetFullPath(
+ $corruptProviderPath).Replace('\', '/')
+ $oversizedProviderPath = Join-Path $testDir `
+ "nonexistent\openssh-oversized-provider.dll"
+ $oversizedProvider = [IO.Path]::GetFullPath(
+ $oversizedProviderPath).Replace('\', '/')
+
+ try {
+ ssh-add -D
+ $LASTEXITCODE | Should Be 0
+
+ Add-PasswordSetting -Pass $keypassphrase
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ cmd /c "ssh-add `"$softwareKeyPath`" < `"$nullFile`""
+ $LASTEXITCODE | Should Be 0
+ Remove-PasswordSetting
+
+ Add-PasswordSetting -Pass $testPin
+ $env:SSH_ASKPASS_REQUIRE = "force"
+ & ssh-add -s $pkcs11Path
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+
+ $providerRootPath = "$currentUserSid\Software\OpenSSH\Agent\PKCS11_Providers"
+ $providerRoot = [Microsoft.Win32.Registry]::Users.OpenSubKey(
+ $providerRootPath, $true)
+ $providerRoot | Should Not Be $null
+ $canonicalProvider = [IO.Path]::GetFullPath($pkcs11Path).Replace('\', '/')
+ $validProviderKey = $providerRoot.OpenSubKey($canonicalProvider)
+ $validProviderKey | Should Not Be $null
+ $encryptedPin = $validProviderKey.GetValue("pin")
+ $encryptedPin -is [byte[]] | Should Be $true
+
+ $staleProviderKey = $providerRoot.CreateSubKey($staleProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($staleProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin", $encryptedPin,
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $unavailableKeyPath
+ $LASTEXITCODE | Should Not Be 0
+
+ $staleProviderKey.Dispose()
+ $staleProviderKey = $providerRoot.CreateSubKey($corruptProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($corruptProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin",
+ [Text.Encoding]::UTF8.GetBytes("invalid encrypted pin"),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+
+ $staleProviderKey.Dispose()
+ $staleProviderKey = $providerRoot.CreateSubKey($oversizedProvider)
+ $staleProviderKey.SetValue("provider",
+ [Text.Encoding]::UTF8.GetBytes($oversizedProvider),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+ $staleProviderKey.SetValue("pin", (New-Object byte[] 11000),
+ [Microsoft.Win32.RegistryValueKind]::Binary)
+
+ & ssh-add -T $softwareKeyPath
+ $LASTEXITCODE | Should Be 0
+ & ssh-add -T $publicKeyPaths[0]
+ $LASTEXITCODE | Should Be 0
+ }
+ finally {
+ if ($staleProviderKey) { $staleProviderKey.Dispose() }
+ if ($validProviderKey) { $validProviderKey.Dispose() }
+ if ($providerRoot) {
+ $providerRoot.DeleteSubKeyTree($staleProvider, $false)
+ $providerRoot.DeleteSubKeyTree($corruptProvider, $false)
+ $providerRoot.DeleteSubKeyTree($oversizedProvider, $false)
+ $providerRoot.Dispose()
+ }
+ ssh-add -D | Out-Null
+ Remove-PasswordSetting
+ }
+ }
}
Context "$tC ssh-keygen known_hosts operations" {
diff --git a/regress/pesterTests/README.md b/regress/pesterTests/README.md
index afd636e76c6d..cd9ac12d0871 100644
--- a/regress/pesterTests/README.md
+++ b/regress/pesterTests/README.md
@@ -64,3 +64,23 @@ Follow these simple steps for test case indexing
AfterAll{$tC++}
```
- Prefix any test out file with $tC.$tI. You may use pre-created $stderrFile, $stdoutFile, $logFile for this purpose
+
+#### PKCS#11 certificate tests
+
+The PKCS#11 certificate scenario in `KeyUtils.Tests.ps1` is enabled when the
+following environment variables are set before running the E2E tests:
+
+* `OPENSSH_TEST_PKCS11_PROVIDER`: absolute path to a PKCS#11 provider DLL.
+* `OPENSSH_TEST_PKCS11_PIN`: token PIN.
+* `OPENSSH_TEST_PKCS11_PUBLIC_KEYS`: semicolon-separated public-key files whose
+ corresponding private keys are present on the token.
+* `OPENSSH_TEST_PKCS11_LABELS`: semicolon-separated labels corresponding
+ positionally to `OPENSSH_TEST_PKCS11_PUBLIC_KEYS`. An empty item expects the
+ canonical provider path fallback.
+
+The test creates short-lived OpenSSH certificates for the supplied public
+keys. It verifies plain and certificate identities, signing, agent service
+restart, individual certificate deletion, cert-only loading, an unmatched
+certificate, and provider removal. Never use production token credentials in
+CI. PKCS#11 PIN input is forced through the test askpass helper so an
+unattended run cannot block on an interactive prompt.
diff --git a/regress/unittests/win32compat/pkcs11_cert_tests.c b/regress/unittests/win32compat/pkcs11_cert_tests.c
new file mode 100644
index 000000000000..c863d7e7d51a
--- /dev/null
+++ b/regress/unittests/win32compat/pkcs11_cert_tests.c
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2026 Sebastian Ott. All rights reserved.
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include "includes.h"
+
+#include "authfd.h"
+#include "sshbuf.h"
+#include "ssherr.h"
+#include "sshkey.h"
+#include "xmalloc.h"
+
+#include "contrib/win32/win32compat/pkcs11-cert.h"
+#include "../test_helper/test_helper.h"
+#include "tests.h"
+
+#define TEST_CERT \
+ "ecdsa-sha2-nistp256-cert-v01@openssh.com " \
+ "AAAAKGVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg" \
+ "OtFRnMigkGliaYfPmX5IidVWfV3tRH6lqRXv0l8bvKoAAAAIbmlzdHAyNTYAAABB" \
+ "BAxZW5ZDq1vcnSlYbTPvQGN3PbGgRO0ht5Rcd/JwWr5AAw2iPY4d/5Lxvybfb6" \
+ "ZttqsKJJUwhg38wpF5CCmlpQcAAAAAAAAABwAAAAIAAAAGanVsaXVzAAAAEgAAAA" \
+ "Vob3N0MQAAAAVob3N0MgAAAAA2jAHwAAAAAE0eYHAAAAAAAAAAAAAAAAAAAABoAA" \
+ "AAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAxZW5ZDq1vcnS" \
+ "lYbTPvQGN3PbGgRO0ht5Rcd/JwWr5AAw2iPY4d/5Lxvybfb6ZttqsKJJUwhg38w" \
+ "pF5CCmlpQcAAABkAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAABJAAAAIHbxGwTnu" \
+ "e7KxhHXGFvRcxBnekhQ3Qx84vV/Vs4oVCrpAAAAIQC7vk2+d14aS7td7kVXLQn3" \
+ "92oALjEBzMZoDvT1vT/zOA== test"
+
+static struct sshkey *
+load_test_cert(void)
+{
+ struct sshkey *key = NULL;
+ char *line = NULL, *cp;
+
+ line = xstrdup(TEST_CERT);
+ cp = line;
+ key = sshkey_new(KEY_UNSPEC);
+ if (key == NULL || sshkey_read(key, &cp) != 0) {
+ sshkey_free(key);
+ key = NULL;
+ }
+ free(line);
+ return key;
+}
+
+static int
+put_associated_certs(struct sshbuf *m, int cert_only,
+ struct sshkey *cert, size_t ncerts)
+{
+ struct sshbuf *b = NULL;
+ size_t i;
+ int r;
+
+ if ((b = sshbuf_new()) == NULL)
+ return SSH_ERR_ALLOC_FAIL;
+ for (i = 0; i < ncerts; i++) {
+ if ((r = sshkey_puts(cert, b)) != 0)
+ goto out;
+ }
+ if ((r = sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION)) != 0 ||
+ (r = sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com")) != 0 ||
+ (r = sshbuf_put_u8(m, cert_only != 0)) != 0 ||
+ (r = sshbuf_put_stringb(m, b)) != 0)
+ goto out;
+ r = 0;
+ out:
+ sshbuf_free(b);
+ return r;
+}
+
+static void
+test_pkcs11_cert_constraints_valid(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0, r;
+
+ TEST_START("PKCS11 associated certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, 1, cert, 1), 0);
+ ASSERT_INT_EQ(r = parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ ASSERT_INT_EQ(cert_only, 1);
+ ASSERT_SIZE_T_EQ(ncerts, 1);
+ ASSERT_INT_EQ(sshkey_equal(cert, certs[0]), 1);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_compatible(void)
+{
+ struct sshbuf *m = NULL, *destinations = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("PKCS11 certificate with existing constraints");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_PTR_NE(destinations = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_LIFETIME), 0);
+ ASSERT_INT_EQ(sshbuf_put_u32(m, 60), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_CONFIRM), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "restrict-destination-v00@openssh.com"), 0);
+ ASSERT_INT_EQ(sshbuf_put_stringb(m, destinations), 0);
+ ASSERT_INT_EQ(put_associated_certs(m, 1, cert, 1), 0);
+ ASSERT_INT_EQ(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ ASSERT_INT_EQ(cert_only, 1);
+ ASSERT_SIZE_T_EQ(ncerts, 1);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(destinations);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_identity_name(void)
+{
+ struct sshkey *cert = NULL, *plain = NULL;
+ u_char *cert_blob = NULL, *plain_blob = NULL;
+ size_t cert_blob_len = 0, plain_blob_len = 0;
+ char *cert_name = NULL, *cert_name_again = NULL, *plain_name = NULL;
+
+ TEST_START("distinct PKCS11 certificate registry identity");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_INT_EQ(sshkey_from_private(cert, &plain), 0);
+ ASSERT_INT_EQ(sshkey_drop_cert(plain), 0);
+ ASSERT_INT_EQ(sshkey_to_blob(cert, &cert_blob, &cert_blob_len), 0);
+ ASSERT_INT_EQ(sshkey_to_blob(plain, &plain_blob, &plain_blob_len), 0);
+ ASSERT_PTR_NE(cert_name = pkcs11_identity_name(cert, cert_blob,
+ cert_blob_len), NULL);
+ ASSERT_PTR_NE(cert_name_again = pkcs11_identity_name(cert, cert_blob,
+ cert_blob_len), NULL);
+ ASSERT_PTR_NE(plain_name = pkcs11_identity_name(plain, plain_blob,
+ plain_blob_len), NULL);
+ ASSERT_INT_EQ(strncmp(cert_name, "cert-", 5), 0);
+ ASSERT_STRING_EQ(cert_name, cert_name_again);
+ ASSERT_STRING_NE(cert_name, plain_name);
+ free(plain_name);
+ free(cert_name_again);
+ free(cert_name);
+ free(plain_blob);
+ free(cert_blob);
+ sshkey_free(plain);
+ sshkey_free(cert);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_identity_comment(void)
+{
+ const char *provider = "C:/provider.dll";
+
+ TEST_START("PKCS11 identity comment fallback");
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, "token label"),
+ "token label");
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, ""), provider);
+ ASSERT_STRING_EQ(pkcs11_identity_comment(provider, NULL), provider);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_duplicate(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("duplicate PKCS11 certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert, 1), 0);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert, 1), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_truncated(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("truncated PKCS11 certificate constraint");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com"), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_malformed(void)
+{
+ struct sshbuf *m = NULL, *b = NULL;
+ struct sshkey **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("malformed PKCS11 certificate constraint");
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_PTR_NE(b = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(sshbuf_put_string(b, "bad", 3), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, SSH_AGENT_CONSTRAIN_EXTENSION), 0);
+ ASSERT_INT_EQ(sshbuf_put_cstring(m,
+ "associated-certs-v00@openssh.com"), 0);
+ ASSERT_INT_EQ(sshbuf_put_u8(m, 0), 0);
+ ASSERT_INT_EQ(sshbuf_put_stringb(m, b), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshbuf_free(b);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+static void
+test_pkcs11_cert_constraints_oversized(void)
+{
+ struct sshbuf *m = NULL;
+ struct sshkey *cert = NULL, **certs = NULL;
+ size_t ncerts = 0;
+ int cert_only = 0;
+
+ TEST_START("oversized PKCS11 certificate constraint");
+ ASSERT_PTR_NE(cert = load_test_cert(), NULL);
+ ASSERT_PTR_NE(m = sshbuf_new(), NULL);
+ ASSERT_INT_EQ(put_associated_certs(m, 0, cert,
+ AGENT_MAX_EXT_CERTS + 1), 0);
+ ASSERT_INT_NE(parse_pkcs11_add_constraints(m, &cert_only,
+ &certs, &ncerts), 0);
+ free_pkcs11_certs(certs, ncerts);
+ sshkey_free(cert);
+ sshbuf_free(m);
+ TEST_DONE();
+}
+
+void
+pkcs11_cert_tests(void)
+{
+ test_pkcs11_cert_constraints_valid();
+ test_pkcs11_cert_constraints_compatible();
+ test_pkcs11_cert_identity_name();
+ test_pkcs11_identity_comment();
+ test_pkcs11_cert_constraints_duplicate();
+ test_pkcs11_cert_constraints_truncated();
+ test_pkcs11_cert_constraints_malformed();
+ test_pkcs11_cert_constraints_oversized();
+}
diff --git a/regress/unittests/win32compat/tests.c b/regress/unittests/win32compat/tests.c
index ae27730dc757..bb110e6f4de8 100644
--- a/regress/unittests/win32compat/tests.c
+++ b/regress/unittests/win32compat/tests.c
@@ -19,6 +19,7 @@ tests()
{
_set_abort_behavior(0, 1);
log_init(NULL, 7, 2, 0);
+ pkcs11_cert_tests();
signal_tests();
socket_tests();
file_tests();
diff --git a/regress/unittests/win32compat/tests.h b/regress/unittests/win32compat/tests.h
index 580cf063f859..64e06f09f0f8 100644
--- a/regress/unittests/win32compat/tests.h
+++ b/regress/unittests/win32compat/tests.h
@@ -3,6 +3,7 @@ void signal_tests();
void socket_tests();
void file_tests();
void miscellaneous_tests();
+void pkcs11_cert_tests(void);
char *dup_str(char *inStr);
void delete_dir_recursive(char *full_dir_path);
diff --git a/ssh-add.c b/ssh-add.c
index b90ae87b8455..137af7e5ce7e 100644
--- a/ssh-add.c
+++ b/ssh-add.c
@@ -855,7 +855,7 @@ main(int argc, char **argv)
skprovider = getenv("SSH_SK_PROVIDER");
#ifdef WINDOWS
- while ((ch = getopt(argc, argv, "vkKlLcdDTxXE:e:M:m:qs:S:t:")) != -1) {
+ while ((ch = getopt(argc, argv, "vkKlLCcdDTxXE:e:M:m:qs:S:t:")) != -1) {
#else
while ((ch = getopt(argc, argv, "vkKlLCcdDTxXE:e:h:H:M:m:qs:S:t:")) != -1) {
#endif
diff --git a/ssh-pkcs11-client.c b/ssh-pkcs11-client.c
index b2f8e0f1e400..b59b7e22381a 100644
--- a/ssh-pkcs11-client.c
+++ b/ssh-pkcs11-client.c
@@ -386,6 +386,8 @@ pkcs11_terminate(void)
// Send message to helper to gracefully unload providers
pkcs11_del_provider(p->name);
TAILQ_REMOVE(&pkcs11_providers, p, next);
+ free(p->name);
+ free(p);
}
if (pid != -1) {
@@ -619,6 +621,7 @@ wrap_key(struct sshkey* k)
#endif /* OPENSSL_HAS_ECC && HAVE_EC_KEY_METHOD_NEW */
} else
fatal_f("unknown key type");
+ k->flags |= SSHKEY_FLAG_EXT;
}
#else
@@ -662,6 +665,50 @@ wrap_key(struct helper *helper, struct sshkey *k)
#ifdef WINDOWS
+/*
+ * Make a private PKCS#11-backed certificate by grafting a previously-loaded
+ * PKCS#11 private key and a public certificate key.
+ */
+int
+pkcs11_make_cert(const struct sshkey *priv,
+ const struct sshkey *certpub, struct sshkey **certprivp)
+{
+ struct sshkey *ret = NULL;
+ int r;
+
+ if (certprivp == NULL)
+ return SSH_ERR_INVALID_ARGUMENT;
+ *certprivp = NULL;
+ debug3_f("private key type %s cert type %s", sshkey_type(priv),
+ sshkey_type(certpub));
+ if (!sshkey_is_cert(certpub) || sshkey_is_cert(priv) ||
+ !sshkey_equal_public(priv, certpub)) {
+ error_f("private key %s doesn't match cert %s",
+ sshkey_type(priv), sshkey_type(certpub));
+ return SSH_ERR_INVALID_ARGUMENT;
+ }
+ if (priv->type != KEY_RSA
+#if defined(OPENSSL_HAS_ECC) && defined(HAVE_EC_KEY_METHOD_NEW)
+ && priv->type != KEY_ECDSA
+#endif
+ ) {
+ error_f("unsupported PKCS11 key type %s", sshkey_type(priv));
+ return SSH_ERR_KEY_TYPE_UNKNOWN;
+ }
+ if ((r = sshkey_from_private(priv, &ret)) != 0)
+ goto out;
+ wrap_key(ret);
+ if ((r = sshkey_to_certified(ret)) != 0 ||
+ (r = sshkey_cert_copy(certpub, ret)) != 0)
+ goto out;
+ *certprivp = ret;
+ ret = NULL;
+ r = 0;
+ out:
+ sshkey_free(ret);
+ return r;
+}
+
static int
pkcs11_start_helper_methods(void)
{