Key Management Architecture#
This document explains the key management subsystem of score::mw::crypto
in detail: how keys come into being, how they are stored, how their lifetime
is managed across multiple clients, and how they are bound to a cryptographic
operation context. A MAC operation is used as the running example because it
touches every layer of the subsystem.
Architecture Overview#
The daemon key management stack has four layers:
Layer |
Responsibility |
|---|---|
Interface layer ( |
Pure abstractions: |
Slot registry layer ( |
|
Core orchestration layer ( |
|
Data-node layer ( |
|
The composition root KeyManagementModule wires these layers at daemon startup and
injects the shared KeyManagementService into every provider.
Class Diagram#
The following diagram shows the key class relationships:
Fig. 9 Key Management — Class Diagram#
Sequence Diagrams#
The following sequence diagram set illustrates daemon startup, slot resolution, key generation, key load with deduplication, MAC context creation with key binding, MAC streaming, and crash cleanup:
Fig. 10 Key Management — Sequence Diagrams#
Key Lifecycle#
Keys enter the daemon through either of two paths and leave via an explicit release or client-crash cleanup.
Slot resolution#
Before a key can be loaded the application resolves a slot name to a
DataNodeId:
The client sends
RESOLVE_RESOURCE(slot_name, kKeySlot)to the mediator.MediatorImplcallsSlotRegistry::ResolveAppResource(slot_name, client_id)which checks the UID-to-resource map and delegates toResolveSlot.AccessPolicyEnforcer::CheckSlotAccessvalidates thatclient_idis in the slot’sallowed_uidslist.A
KeySlotDataNode(slot_handle, slot_registry)is stored in theDataManagerunder the client’s session node. The node is small (~24 bytes) — it holds only theSlotHandleindex and a shared pointer to theSlotRegistry; noKeySlotConfigis copied.The node’s
DataNodeIdis returned to the client as the slot handle for subsequent operations.
Key generation (ephemeral)#
When the client calls KeyManagementContext::GenerateKey the IPC path is:
KEY_GENERATEreachesMediatorImpl::ForwardSingleOperationand is dispatched to theContextDataNode’s handler (OpenSslKeyManagementHandlerorPkcs11KeyManagementHandler).The handler delegates to its
KeyManagementExecutor::HandleGenerate.IKeyFactory::GenerateKey(KeyGenerationRequest)is called:OpenSSL:
RAND_bytes→ heap-allocated buffer →OpenSslKeyHandlerPKCS#11:
C_GenerateKeywithCKA_TOKEN=false(session key) →Pkcs11KeyHandler
KeyManagementService::RegisterKeyMaterialis called with the new handler:A
KeyEntryis created (owns theIKeyHandler).KeyRegistry::RegisterEphemeralKeyassigns aKeyRegistryIdand stores the node.A
KeyDataNodeis added under the calling context node in theDataManager. Its constructor callskey_entry->AddRef(client_id)(ref-count = 1).
The
DataNodeIdof theKeyDataNodeis returned to the client as aCryptoResourceGuardwrapping akKeyresource.
Loading a pre-deployed slot key (with deduplication)#
When the client calls KeyManagementContext::LoadKey(slot_node_id):
KEY_LOADis forwarded to the provider handler’s executor.KeyManagementExecutor::HandleLoadcallsKeyManagementService::ResolveSlotForOperation(client_id, slot_node_id)which returnsSlotResolution{config*, slot_handle}.KeyManagementService::LoadOrShareis called:KeyRegistry::FindBySlot(slot_handle)checks whether the slot is already loaded.Already loaded: the existing
KeyEntryis reused; a newKeyDataNodeis added to the current client’s tree and its constructor callskey_entry->AddRef(client_id). No provider I/O occurs.First load:
IKeySlotHandler::LoadKey(*config)is called (file read or PKCS#11C_FindObjects), a newKeyEntryis created,KeyRegistry::RegisterSlotKeystores it, and aKeyDataNodeis added as before.
This deduplication is critical for PKCS#11 tokens, where loading the same token object twice would either produce a redundant handle or fail.
Key release#
- Explicit:
The client calls
CryptoResourceGuard::~CryptoResourceGuardon the key guard, which sendsKEY_RELEASE. The executor callsKeyManagementService::ReleaseKeyMaterial(client_id, ref_node_id)→DataManager::deleteNode→~KeyDataNode()→key_entry->Release(client_id). If that was the last reference, the unregister callback fires:KeyRegistry::Unregister(registry_id)drops the registry’sshared_ptr, destroying theKeyEntry:IKeyHandler::Release()zeroizes key material.- Implicit (context close):
CTX_CLOSEcallsDataManager::deleteNodeon theContextDataNode. All childKeyDataNodeentries (bound viaBindKeyToContext) are cascade-deleted in the same call, triggering the same chain.- Client crash:
DataManager::deleteClientNodes(client_id)performs a post-order tree traversal, deleting all nodes in the client’s subtree (cascade destruction ofKeyDataNodeentries).KeyManagementService::CleanupClientis also called as a safety net — it iterates everyKeyRegistryand callsRelease(client_id)on everyKeyEntrythat still references that client.
MAC Operation Example#
This section traces a complete HMAC-SHA256 operation from application code
down to the OpenSSL EVP_MAC API.
Step 1 — Resolve the key slot#
// Application code (client side)
auto stack = CreateCryptoStack(stack_config).value();
auto ctx = stack->CreateCryptoContext().value();
// Resolve the pre-deployed HMAC key slot
auto slot = ctx->ResolveResource("HmacProductionSlot",
ResourceType::kKeySlot).value();
// slot is a CryptoResourceGuard wrapping type=kKeySlot
Daemon side: RESOLVE_RESOURCE("HmacProductionSlot", kKeySlot) →
SlotRegistry::ResolveAppResource → AccessPolicyEnforcer::CheckSlotAccess
→ KeySlotDataNode stored in DataManager → slot_node_id returned.
Step 2 — Load the key#
// Create a key management context and load the key explicitly.
// This step allows reuse of the same key across multiple MAC contexts.
auto km = ctx->CreateKeyManagementContext(KeyManagementContextConfig{}).value();
auto key_guard = km->LoadKey(slot).value();
// key_guard wraps type=kKey, persistence=kPersistent
Daemon side:
KEY_LOADis forwarded toOpenSslKeyManagementHandler::Execute(OpenSSL provider context).KeyManagementExecutor::HandleLoad→ResolveSlotForOperation→LoadOrShare:KeyRegistry::FindBySlotreturnsnullptr(first load).FileBackedSlotHandler::LoadKey(config)reads the key file at the path stored inconfig.deployment_path(viaDeploymentLoader) and constructs anOpenSslKeyHandler.KeyRegistry::RegisterSlotKeystores the newKeyEntry(ref_count = 0).CreateKeyDataNodecreates aKeyDataNodeunder the key-management context node; its constructor callskey_entry->AddRef(client_id)and setsref_count = 1.
key_ref_node_idreturned to client →CryptoResourceGuard.
Step 3 — Create the MAC context (context creation with key binding)#
MacContextConfig mac_cfg;
mac_cfg.SetAlgorithm("HMAC-SHA256").SetKey(key_guard);
auto mac = ctx->CreateMacContext(mac_cfg).value();
Daemon side — CTX_CREATE(type="MAC", algo="HMAC-SHA256",
provider=SOFTWARE, key_node_id=key_ref_node_id):
Provider routing:
KeyManagementService::ResolveTargetProvider( client_id, SOFTWARE, key_ref_node_id)examines theKeyEntry’sprovider_id("openssl"). Since the key lives in OpenSSL and the requested type isSOFTWARE, the resolved provider is"openssl".Handler creation:
ProviderManager::GetProvider("openssl")→ICryptoHandlerFactory::CreateHandler("MAC", "HMAC-SHA256")→new MacHandler(MacExecutor, "HMAC-SHA256").Context node:
DataManager::addChildNodecreates aContextDataNodewrapping theMacHandlerImpl; theDataNodeIdbecomescontext_node_id.Key binding:
KeyManagementService::BindKeyToContext(client_id, context_node_id, key_ref_node_id, "openssl"):The
KeyDataNodeis located viaDataManager::getNodeAccessor.A new
KeyDataNodeis added as a child of theContextDataNode; its constructor callskey_entry->AddRef(client_id), incrementingref_countto 2.Returns
KeyBindingResult{key_handler_sptr, resolved_node_id}.
Initialization:
MediatorImplbuilds:InitializationParams params{ .client_id = client_id, .context_node_id = context_node_id, .provider_id = 0, // numeric ID assigned by ProviderManager .key_node_id = key_ref_node_id, .bound_key_handler = key_binding_result.key_handler.get() // non-owning raw pointer, valid during init only };
Then calls
MacHandlerImpl::InitializeContext(params).Handler initialization (
OpenSslHmacHandler::InitializeContext):Validates
m_algorithm == "HMAC-SHA256".Calls
EVP_MAC_fetch(NULL, "HMAC", NULL)to obtain anEVP_MAC*object, thenEVP_MAC_CTX_new(m_mac)to allocate theEVP_MAC_CTX.Checks
params.bound_key_handler != nullptr.Verifies
bound_key_handler->GetProviderId() == 0(numeric ID; type-safety without RTTI).Downcast:
static_cast<const OpenSslKeyHandler*>( params.bound_key_handler)→ safe because the ProviderId tag was verified.Calls
GetRawKeyBytes(key_len)to obtain a direct pointer to the heap-allocated key material.Stores
init_params; the actualEVP_MAC_init(m_ctx, key_bytes, key_len, params)call (withOSSL_PARAMselecting the digest) is deferred toInitMac()so the context can be re-initialized onMAC_INITwithout re-fetching the MAC object.
At the end of CTX_CREATE the daemon returns context_node_id to the
client. The key material is ready to be consumed by EVP_MAC_init;
the OpenSslKeyHandler retains the authoritative copy until it is released.
Step 4 — Perform MAC operations#
mac->Update(span_of_data);
mac->Update(more_data);
auto mac_tag = mac->Finalize().value();
Daemon side — each Update becomes MAC_UPDATE:
MediatorImpl::ForwardSingleOperationlooks up theContextDataNodebycontext_node_id→MacHandlerImpl::Execute(MAC_UPDATE, params).MacExecutor::Executevalidates the stream transition (IDLE → STREAM_INITIALIZEDon first update;STREAM_INITIALIZED → STREAM_ACTIVEon subsequent updates) and callsMacHandlerImpl::UpdateMac(dataToMac).The handler extracts raw bytes via
ExtractBufferDatathen callsEVP_MAC_update(m_ctx, data, len)to feed data into the running HMAC.
MAC_FINALIZE:
MacExecutorvalidatesSTREAM_INITIALIZED/ACTIVE → IDLEtransition.OpenSslHmacHandler::FinalizeMac→EVP_MAC_final(m_ctx, output, &hmac_len, buf_len)writes the 32-byte HMAC-SHA256 tag into the client-providedVirtualMemoryBuffer.
Step 5 — Release resources#
mac.reset(); // ~MacContext() → CTX_CLOSE
key_guard.reset(); // ~CryptoResourceGuard() → KEY_RELEASE
- CTX_CLOSE (
mac.reset()): DataManager::deleteNode(client_id, context_node_id)cascade-deletes:ContextDataNodedestroyed.Child
KeyDataNode(bound at step 3) destroyed: callskey_entry->Release(client_id)→ref_count = 1.HMAC context freed via
OpenSslHmacHandler::~OpenSslHmacHandler→EVP_MAC_CTX_free+EVP_MAC_free.
- KEY_RELEASE (
key_guard.reset()): DataManager::deleteNode(client_id, key_ref_node_id)destroys the originalKeyDataNode(from step 2): callskey_entry->Release(client_id)→ref_count = 0→ unregister callback →KeyRegistry::Unregister(registry_id)→ registry drops itsshared_ptr→~KeyEntry()→IKeyHandler::Release()(OPENSSL_cleanse+delete[]).
The key material is now securely zeroized.
Thread Safety#
The key management subsystem uses a three-level lock hierarchy:
Level |
Lock |
Protects |
|---|---|---|
1 (highest) |
|
Node tree structure: add, delete, lookup |
2 |
|
|
3 (lowest) |
|
|
Rule: never acquire a lower-level lock while holding a higher-level lock. In practice:
ReleaseKeyMaterialcallsDataManager::deleteNode(acquires Level 1).The resulting
~KeyDataNodecallskey_entry->Release(Level 3) after theDataManagerlock is released.The unregister callback calls
KeyRegistry::Unregister(Level 2) only after ref-count reaches zero — at that point no DataManager lock is held.
KeyEntry::m_ref_count is std::atomic<uint32_t> for lock-free
increment/decrement; the m_ref_mutex only serializes the
m_referencing_clients vector updates inside AddRef / Release.
Multi-Client Key Deduplication#
When multiple client processes resolve and load the same slot simultaneously:
App1: RESOLVE_RESOURCE("HmacSlot") → slot_node_id_A
App2: RESOLVE_RESOURCE("HmacSlot") → slot_node_id_B (independent node)
App3: RESOLVE_RESOURCE("HmacSlot") → slot_node_id_C
App1: KEY_LOAD(slot_node_id_A) → key_ref_node_id_1 (first load → LoadKey)
App2: KEY_LOAD(slot_node_id_B) → key_ref_node_id_2 (slot loaded → reuse)
App3: KEY_LOAD(slot_node_id_C) → key_ref_node_id_3 (slot loaded → reuse)
KeyRegistry: 1 × KeyEntry (ref_count=3)
Each KeyDataNode is owned by the respective client’s tree.
key_entry->Release is called three times (once per client when the
KeyDataNode destructs); only the last call triggers destruction and
zeroization.
Concurrent load race: if two threads reach LoadOrShare before either
has registered, both call IKeySlotHandler::LoadKey. The first call to
KeyRegistry::RegisterSlotKey wins; the losing thread detects the
conflict, looks up the winning node via FindBySlot, and creates a
KeyDataNode on it. The losing IKeyHandler is released
immediately — no key material leaks.
Access Control#
PKCS#11 Session Management#
The PKCS#11 provider manages sessions, login state, and key object lifetime differently from the OpenSSL provider. This section documents the design decisions and their rationale.
Session Pools#
Each Pkcs11Provider maintains two pools of PKCS#11 sessions — one for
Read-Only (RO) and one for Read-Write (RW) operations. The pools are
protected by m_poolMutex so that the gRPC thread pool can acquire and
release sessions concurrently.
Session acquisition:
AcquireSessionscans the pool for an idle session.If no idle session exists and the pool is below its hard limit (from
C_GetTokenInfo.ulMaxSessionCount), a new session is opened viaC_OpenSession.For
kUseraccess,TokenAuthGuard::EnsureUserStateis called after the session is acquired, ensuringC_Loginis called once per module-slot pair.
Session key pinning#
PKCS#11 v2.40 §5.7 states that session objects (CKA_TOKEN=false) are
destroyed when the session that created them is closed. They are visible to
all sessions of the same application, but the creating session must remain
open.
This means GenerateKey and ImportKey must not release the session
used to call C_GenerateKey / C_CreateObject. The session handle is
stored alongside the key object handle in Pkcs11KeyStore.
Token objects (CKA_TOKEN=true) loaded via C_FindObjects do not have
this constraint — their lifetime is independent of any session.
Thread-safe login state#
TokenAuthGuard maintains a reference-counted login state:
EnsureUserState: ifm_activeUserCount == 0, callsC_Login; otherwise increments the counter. Protected bym_mutex.OnUserHandlerReleased: decrements the counter; callsC_Logoutwhen it reaches zero. Protected bym_mutex.
The mutex is essential because the gRPC daemon’s thread pool can dispatch concurrent crypto operations that each require a logged-in session.
Session validation#
Before executing a cryptographic operation, the handler calls
Pkcs11Provider::ValidateSession(session) which invokes
C_GetSessionInfo. If the session has become invalid (e.g. device
removal), the operation returns kSessionInvalid immediately instead of
propagating a cryptic PKCS#11 error code.
Multi-Token Coexistence#
The Pkcs11ProviderFactory supports multiple tokens from the same
PKCS#11 library (e.g. multiple SoftHSM slots). Each Pkcs11TokenEntry
in Pkcs11Config becomes a separate Pkcs11Provider instance that
shares the Pkcs11Module (and thus C_Initialize is called once).
The visitor pattern drives configuration:
config.GetPkcs11Config().PopulateDefaults();
auto factory = std::make_unique<Pkcs11ProviderFactory>();
config.GetPkcs11Config().Configure(*factory); // visitor call
manager.RegisterFactory(std::move(factory));
Pkcs11Config::Configure() converts each Pkcs11TokenEntry to a
Pkcs11ProviderConfig and calls factory.SetTokenConfigs(); the
entire mapping logic lives in pkcs11_token_config.cpp and does not
leak into daemon.cpp or config.hpp.
Each provider has its own session pool, its own TokenAuthGuard, and
its own Pkcs11KeyStore. Login state, sessions, and key registrations
are fully isolated between tokens.
Access Control#
Access decisions are centralized in AccessPolicyEnforcer. The enforcer
is called at two independent points:
Slot resolution (
SlotRegistry::ResolveSlot): verifiesclient_idis inconfig.access_policy.allowed_uids.Slot write operations (generate-to-slot, import-to-slot):
CheckWritePermissionverifies membership inconfig.access_policy.allowed_write_uids.Operation permission (before any crypto use):
CheckOperationPermissionvalidates the required permission bits (e.g.kMac) againstconfig.allowed_operations.Provider access (before write):
CheckProviderAccess(config, provider_id, is_write)— writes are only allowed via the primary provider (provider_ids[0]); reads may use any listed provider.
This “defense in depth” ensures that even if a request bypasses the mediator’s routing, the enforcer rejects unauthorized operations.
Configuration#
Each key slot is described by a KeySlotConfig:
Field |
Type |
Description |
|---|---|---|
|
|
Human-readable unique name used in |
|
|
Algorithm string (e.g. |
|
|
Config-time: ordered list of human-readable provider names from JSON. Index 0 is primary. |
|
|
Runtime: ordered list of numeric IDs resolved from |
|
|
Bitmask: |
|
|
|
|
|
Absolute filesystem path to the key’s deployment file.
Read by |
|
|
Serialization format token (e.g. |
Deployment Descriptor#
All dynamic per-slot data that is too volatile to bake into a compiled catalog
is stored in a deployment descriptor file referenced by
KeySlotConfig::deployment_path. The file is read at slot load time by
DeploymentLoader and written back after a key update by DeploymentWriter.
Format-extensible design
The DeploymentLoader / DeploymentWriter classes are thin façades. After
validating the path they delegate to a format-specific implementation that
implements IDeploymentLoader / IDeploymentWriter:
slot/
deployment_loader.hpp/.cpp ← façade (public API unchanged for all callers)
deployment_writer.hpp/.cpp ← façade
deployment/
deployment_path_utils.hpp ← IsDeploymentPathSafe() — shared guard
i_deployment_loader.hpp ← pure-virtual interface
i_deployment_writer.hpp ← pure-virtual interface
kv/
kv_deployment_loader.hpp/.cpp ← current implementation
kv_deployment_writer.hpp/.cpp
json/ ← reserved (add JsonDeploymentLoader when needed)
flatbuffer/ ← reserved
To add a new format: implement IDeploymentLoader / IDeploymentWriter under
slot/deployment/<format>/, then add one if-branch in each façade .cpp
and one dep in slot/deployment/BUILD. No other files change.
Key=value format (``”kv”``) — file layout
# comments are ignored; blank lines are ignored
[metadata]
availability = active
provisioned_at = 2025-11-03T08:42:00Z
update_counter = 1
hash = sha256:a1b2c3d4...
kek.keyslotname = vehicle/master-key
kek.algo = AES-256-GCM
kek.iv = 0102030405060708090a0b0c
[key]
key_path = /etc/crypto/keys/hmac.bin
key_format = raw
# key = <hex or base64 plain-text key — testing/dev only, not for production>
Well-known metadata keys (metadata_keys namespace in key_slot_config.hpp):
Key |
Meaning |
|---|---|
|
Slot state override: |
|
ISO-8601 UTC timestamp of last successful key provisioning |
|
Monotonically increasing decimal string; incremented on every key replacement |
|
Hex-encoded digest of the key material (e.g. |
|
Slot name of the Key Encryption Key used to wrap/unwrap this key |
|
Algorithm of the Key Encryption Key (e.g. |
|
Hex-encoded IV for KEK operations |
Well-known deployment keys (deployment_keys namespace in key_slot_config.hpp):
Key |
Meaning |
|---|---|
|
Filesystem path to the key material file (file-backed providers) |
|
Encoding of the file: |
|
Plain-text key material (hex/base64). For testing/development only. |
|
PKCS#11 |
|
PKCS#11 |
|
|
|
TEE / PSA persistent key identifier |
|
PSA Crypto key identifier (uint32 as decimal string) |