Skip to main content

Credentials

How the SDK generates, encrypts, hashes, and rotates device credentials. The canonical contract lives in the credentials spec.

ICredentialsService

public interface ICredentialsService
{
string GenerateKey(); // 256-bit base64 random key
string HashKey(string plainTextKey); // bcrypt(plainText) at BcryptWorkFactor
string Encrypt(string plainText); // AES-256-GCM, current master key
string Decrypt(string encryptedText); // AES-256-GCM with key fallback
string? ReEncrypt(string encryptedText); // bulk re-encrypt to current key, null if already current
}

ReEncrypt is the bulk re-encryption entry point exercised by the fleet-level master-key-rotation tests. It returns null when the input was already encrypted with the current key — callers can use that to skip the database write.

Lifecycle

When a device is provisioned the SDK:

  1. Generates a keyGenerateKey() returns a base64-encoded 256-bit random key.
  2. Encrypts at restEncrypt(plain) produces the ciphertext stored in device_entity.document (primary and secondary keys both encrypted).
  3. Hashes for the brokerHashKey(plain) produces a bcrypt hash, surfaced to your adapter as ProvisionContext.PrimaryKeyHash / SecondaryKeyHash so a broker auth DB (e.g. mosquitto-go-auth) can authenticate the device without ever seeing the plaintext.
  4. Returns plaintext once — to the onboarding service / caller. The plaintext is then forwarded to the device and discarded.
GenerateKey() → plaintext
├── Encrypt(plaintext) → device_entity.document (AES-256-GCM)
├── HashKey(plaintext) → ProvisionContext.*Hash (bcrypt, broker auth DB)
└── return plaintext → onboarding service (delivered to device once)

HashKey runs outside the database transaction, on a bounded threadpool — bcrypt at the default work factor of 10 is ~300 ms of CPU per hash. See Operational Notes › bcrypt threadpool offload.

Encryption at rest (AES-256-GCM)

Ciphertext layout — base64-encoded:

[version: 1B][nonce: 12B][tag: 16B][ciphertext: var]
  • version — master-key version (1-indexed). Decryption picks the matching key from Credentials:PreviousMasterKeys[version-1] (or the current Credentials:MasterKey when version is the latest).
  • nonce — 96-bit IV, fresh per encryption.
  • tag — GCM authentication tag.

A legacy unversioned format is still accepted on read. New writes always use the versioned format.

The master key validation at startup is strict — an absent or non-32-byte Credentials:MasterKey fails the host fast in any non-Development environment. The example key AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= (all zeros) is for development only.

Generating a master key

openssl rand -base64 32

Store the result in a Kubernetes Secret, Azure Key Vault, or equivalent — never in appsettings.json checked into the repo.

Master-key rotation

The SDK supports versioned master keys via Credentials:PreviousMasterKeys. Rotation is online and does not require a downtime window:

{
"Credentials": {
"MasterKey": "<new-base64-32-byte>",
"PreviousMasterKeys": [
"<previous-base64-32-byte>"
]
}
}

Order is oldest → newest — index 0 = version 1, index 1 = version 2. The current MasterKey gets the next version number after the last entry.

Runbook

  1. Add a new key alongside the current one:
    • Move the existing MasterKey into the next slot of PreviousMasterKeys.
    • Set MasterKey to the new value.
    • Roll the deployment. All decrypts continue to work (fallback through previous keys); new encrypts use the new key.
  2. Bulk re-encrypt the fleet by walking each device row and calling ICredentialsService.ReEncrypt(currentCipher):
    • If it returns a non-null value, write it back.
    • If it returns null, the row is already on the new key — skip.
  3. Drop the old key once every row is on the new key. Remove the old entry from PreviousMasterKeys and roll again.

The SDK fails loudly if a ciphertext references a version not in the configured key ring — there is no silent fallback. This is by design: a missing key is a configuration error, not a data error, and silent loss would be worse than a startup failure.

Device key rotation (primary ↔ secondary)

Each device has two slots, PrimaryKey and SecondaryKey, both encrypted with the master key. The slots support zero-downtime rotation of a device's own credential:

  1. Device authenticates with PrimaryKey.
  2. Generate a new key, write to SecondaryKey, push the new bcrypt hash to the broker auth DB.
  3. Device switches to SecondaryKey.
  4. Promote: move SecondaryKeyPrimaryKey, generate a fresh SecondaryKey.

The slots are also useful for blue/green credential cuts where the device may briefly hold two valid keys.

Bcrypt cost factor

Credentials:BcryptWorkFactor controls the bcrypt computation cost. Higher values are more secure but slower:

Factor≈ TimeUse case
8~40 msDevelopment, high-throughput provisioning soak
10~300 msDefault, good balance for production
12~1.2 sHigh-security environments with low provisioning rates

Hashing always runs outside the provisioning DB transaction and on a bounded threadpool — do not move it elsewhere. See Operational Notes › bcrypt threadpool offload.

Security best practices

  • Never log plaintext keys. The SDK returns plaintext only during provisioning. Deliver to the device and discard.
  • Plan rotation. Document which MasterKey version is in use and run rotations on a schedule (annual at minimum).
  • Use real secret stores. MasterKey, broker passwords, and connection strings belong in Kubernetes Secrets / Azure Key Vault — never in ConfigMaps or repo-checked appsettings.
  • Restrict credential endpoints. GET /api/devices/{id}/keys and GET /devices/{id}/credentials require elevated authorization (DeviceAdmin policy in the reference host).
  • Audit credential access. The CloudEvents catalogue includes device.provisioned, device.deleted, device.certificate-revoked — subscribe to these for an audit trail.