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:
- Generates a key —
GenerateKey()returns a base64-encoded 256-bit random key. - Encrypts at rest —
Encrypt(plain)produces the ciphertext stored indevice_entity.document(primary and secondary keys both encrypted). - Hashes for the broker —
HashKey(plain)produces a bcrypt hash, surfaced to your adapter asProvisionContext.PrimaryKeyHash/SecondaryKeyHashso a broker auth DB (e.g. mosquitto-go-auth) can authenticate the device without ever seeing the plaintext. - 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 fromCredentials:PreviousMasterKeys[version-1](or the currentCredentials:MasterKeywhenversionis 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
- Add a new key alongside the current one:
- Move the existing
MasterKeyinto the next slot ofPreviousMasterKeys. - Set
MasterKeyto the new value. - Roll the deployment. All decrypts continue to work (fallback through previous keys); new encrypts use the new key.
- Move the existing
- 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.
- Drop the old key once every row is on the new key. Remove the old entry from
PreviousMasterKeysand 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:
- Device authenticates with
PrimaryKey. - Generate a new key, write to
SecondaryKey, push the new bcrypt hash to the broker auth DB. - Device switches to
SecondaryKey. - Promote: move
SecondaryKey→PrimaryKey, generate a freshSecondaryKey.
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 | ≈ Time | Use case |
|---|---|---|
| 8 | ~40 ms | Development, high-throughput provisioning soak |
| 10 | ~300 ms | Default, good balance for production |
| 12 | ~1.2 s | High-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
MasterKeyversion 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}/keysandGET /devices/{id}/credentialsrequire elevated authorization (DeviceAdminpolicy 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.