The provider, Kubernetes, and OpenBao share this wire contract. Changing any of it is a breaking change; see Compatibility . For what the contract protects, see Security: AAD and decrypt validation .
key_id format
Kubernetes treats key_id as public, stable, never reused, and never
flip-flopping. The provider’s key_id is an opaque hash that changes with
every Transit key version and never exposes a key name, mount path, namespace,
or bare version number:
obk2.<base64url-sha256>
sha256(
"openbao-kubernetes-kms/key-id/v1" || 0x00 ||
provider_name || 0x00 ||
cluster_id || 0x00 ||
openbao_instance_id || 0x00 ||
openbao_namespace || 0x00 || # only when configured
transit_mount_id || 0x00 ||
transit_key_lineage_id || 0x00 ||
transit_key_version || 0x00 ||
transit_version_created_at_unix
)The first six inputs come from the provider configuration and must never
change; see Configuration: Identity-bearing fields
.
transit_mount_id is a label you choose, never the OpenBao mount accessor,
which changes on remount or restore. The lineage ID changes only when the key
is deleted and recreated, so a recreated key never matches old ciphertext.
The version and its creation time come from Transit metadata.
The creation time is normalized to Unix seconds before derivation, storage, or
comparison. Sub-second representation changes are tolerated. A different
second for an active or retained version, typically after a restore or import,
makes the provider fail closed, because old key_id values might no longer
describe a decryptable key.
Annotations
Annotations are stored in plaintext with the encrypted object, so they hold only non-secret values, and raw topology is hashed:
provider.kms.openbao.org: "openbao-transit"
key-id-hash.kms.openbao.org: "<base64url-sha256-key-id>"
transit-key-version.kms.openbao.org: "2"
transit-mount-hash.kms.openbao.org: "<base64url-sha256-mount-id>"
transit-key-hash.kms.openbao.org: "<base64url-sha256-key-lineage-id>"
openbao-namespace-hash.kms.openbao.org: "<base64url-sha256-namespace>" # only when configured
plugin-version.kms.openbao.org: "0.1.0"
aad-version.kms.openbao.org: "v1"Keys are fully qualified domain names. Unknown required annotation versions, annotations that disagree with the key snapshot, and oversized annotations are rejected. OpenBao request IDs are never stored in annotations.
AAD envelope
The provider sends this canonical JSON, base64-encoded, as Transit
associated_data on every encryption and decryption:
{
"aad_version": "v1",
"purpose": "kubernetes-etcd-kms-v2",
"provider": "openbao-transit",
"provider_name": "openbao-kms-workload-a",
"cluster_id_hash": "base64url-sha256(cluster-id)",
"openbao_instance_hash": "base64url-sha256(openbao-instance-id)",
"openbao_namespace_hash": "base64url-sha256(openbao-namespace)",
"transit_mount_hash": "base64url-sha256(transit-mount-id)",
"transit_key_hash": "base64url-sha256(transit-key-lineage-id)",
"key_id_hash": "base64url-sha256(kubernetes-key-id)",
"key_version": "3"
}The envelope holds no secrets, raw paths, or raw key names; the namespace hash appears only when a namespace is configured. The annotations carry enough to rebuild the same bytes on decryption, and a missing required field fails the decryption. AAD is always required.
Decrypt validation order
- Parse the
key_id. - Find the matching active, pending, or retired snapshot. For an unknown
key_id, run one rate-limited metadata lookup, and validate and save any new identity before accepting it. - Validate annotation keys and versions.
- Validate annotation hashes against the snapshot.
- Rebuild the AAD.
- Call Transit decrypt.
Every failure in steps 1 to 5 happens before OpenBao is called.
Local registry state
The registry at state.path is a non-secret JSON file with a schema version, a
configuration identity fingerprint, a
monotonic generation, the previous and current state hashes, the active
key_id, and every observed and promoted key snapshot. It keeps rotation
decisions across restarts and lets decryption find historical snapshots
without asking Transit. An adjacent checkpoint records the last accepted
generation and hash. Neither file holds key material, plaintext, credentials,
raw key names, or raw mount paths.
Schema keyregistry.openbao-kms/v1alpha2 requires the cfg1. configuration
fingerprint, including transit.mountPath and transit.keyName. The fingerprint
is covered by the state hash and remains unchanged across rotation and
retirement. Changing either backend path fails startup even when the new key
has identical version creation timestamps. These paths do not enter key_id
derivation or canonical AAD; the wire format remains unchanged.
Preview.3 requires fresh installations. It rejects older unbound registry schemas without rewriting state or checkpoint. Do not delete the old files to bypass this rejection. See Compatibility .
The provider checks on load that:
- the file is a regular file, not a symlink, without group write, execute bits, or world access, in a directory that is not group or world writable,
- the JSON has no unknown fields and its current hash matches the body, with
well-formed hashes and no duplicate
key_idrecords, - the state matches the checkpoint, or is its immediate next generation with a previous hash matching the checkpoint’s hash,
- the state matches the configured provider, cluster, OpenBao instance, namespace, mount, lineage, key name, and AAD mode,
- the creation time of every active, pending, and retired version matches Transit metadata to the second,
min_available_versionandmin_decryption_versionblock no retained version.
State and checkpoint reads open the immediate parent without following a symlink, then open the file relative to that directory descriptor. Type and permission checks apply to the opened file. Keep ancestor directories under trusted host administration; these checks do not protect against a host administrator or another process running as the provider’s user.
During operation:
- pending snapshots decrypt once their metadata is validated; rejected and removed snapshots never do,
- normal rotation keeps every accepted identity; only operator retirement
removes decryption, and its
removedrecords never change afterwards, - the active version never moves backwards,
- skipped Transit versions are kept as decrypt-only snapshots, and missing creation metadata for one fails closed,
min_encryption_versionblocking the active version makes Status and Encrypt unhealthy while observation continues.
When both files are missing, startup creates state only for an unrotated key:
latest_version is 1, and neither min_available_version nor
min_decryption_version excludes it. In every other case, startup fails closed
until the state and checkpoint are restored; see
Disaster recovery: Local registry state
.
A checkpoint without its state file, or with an older one, also fails startup.
An unrelated higher generation or a generation gap fails startup too. Only a
direct hash-linked successor can advance an older checkpoint, to complete a
save interrupted between writing the state and checkpoint. Restore both files
from the same backup or peer; generation numbers are local to each node.
serve, retire-versions --apply, and restore-versions --apply take the same persistent
<state.path>.lock before writing, which needs a local filesystem with working
advisory locks. The state directory must be owned by the process’s OS user.
The checkpoint detects corruption, unsafe restores, and replayed older state. It does not stop a host administrator who replaces both files with a consistent pair; environments that need that add host controls such as immutable backups, measured boot, or an external generation record.