| title | Encryption (KMS) plugin contract | ||||
|---|---|---|---|---|---|
| description | The kms.Provider interface — KEK custody, DEK wrapping, crypto-shred. | ||||
| tags |
|
A KMS provider holds the Key-Encryption Key (KEK) — the
master key that wraps every backup's per-backup
Data-Encryption Key (DEK). Each manifest stamps a
KEKRef string; resolving that string to a working
provider is what lets restore decrypt the chunks.
This is the KEK-side plugin tier. The chunk-side
encryption codecs (AES-256-GCM today, AES-256-GCM-SIV in
v0.5+) live in internal/plugin/encryption.Encryptor —
a separate, lower-level contract; see
Compression contract for the
on-disk envelope they share.
!!! note "Reference implementations"
- internal/plugin/kms/awskms/awskms.go —
AWS KMS, FIPS-grade, supports
ScheduleKeyDeletion-based crypto-shred.
- internal/plugin/kms/vaulttransit/vaulttransit.go —
HashiCorp Vault Transit; on-prem-friendly, FIPS via
Vault Enterprise.
The local-keystore provider at
internal/keystore/ is the dev / single-host default
(local:default) and a useful minimal example of the
interface shape.
// internal/kms/kms.go
package kms
type Provider interface {
Name() string // "local", "aws-kms", "vault-transit"
KEKRef() string // round-trips through manifest
WrapDEK(ctx context.Context, dek []byte) ([]byte, error)
UnwrapDEK(ctx context.Context, wrapped []byte) ([]byte, error)
Shred(ctx context.Context) error
FIPSMode() bool
Close() error
}Implementations are stateful (they hold connection
state, refresh tokens, SDK clients) but every method is
goroutine-safe. A single Provider is reused across
the lifetime of one repo session.
Every manifest carries the kek_ref string that
identifies which provider unlocks it. Format conventions:
| Scheme | Example | Provider |
|---|---|---|
local |
local:default |
On-disk keystore |
aws-kms |
aws-kms://arn:aws:kms:us-east-1:123456789012:key/abcd1234-... |
AWS KMS by ARN |
aws-kms |
aws-kms://alias/pg-hardstorage-prod |
AWS KMS by alias |
aws-kms |
aws-kms://12345678-1234-1234-1234-123456789012 |
AWS KMS by key-id |
gcp-kms |
gcp-kms://projects/p/locations/global/keyRings/r/cryptoKeys/k |
GCP KMS (planned) |
azure-kv |
azure-kv://<vault>/<key> |
Azure Key Vault (planned) |
vault-transit |
vault-transit://<vault-addr>/<key-name> |
HashiCorp Vault Transit |
pkcs11 |
pkcs11://<token>/<label> |
PKCS#11 HSM (planned) |
The scheme is everything before the first : or ://.
kms.SchemeOf(kekRef) does the parsing; use it instead of
manual string-splitting.
KEKRef() round-trips: provider.KEKRef() == manifest.Encryption.KEKRef. A provider that mints a
fresh KEK at Open MUST stamp the same string into both
places.
Lowercase scheme name ("local", "aws-kms",
"vault-transit", …). Stable across versions; goes into
audit-log subject.kek_provider fields and into
pg_hardstorage doctor output.
The manifest-stamped reference this provider resolves.
Round-trips with manifest.Encryption.KEKRef.
Encrypts the per-backup DEK with the cloud-side KEK and
returns the wrapped form. The wrapped bytes go into
manifest.Encryption.WrappedDEK.
dek is 32 bytes (AES-256-key length, see
encryption.KeyLen). The wrapped form is provider-
specific opaque bytes; pg_hardstorage never inspects
them.
The KEK material itself MUST NOT leave the cloud HSM /
on-prem HSM; only Encrypt / Decrypt ciphertext blobs
cross the wire. This is the AWS KMS / GCP KMS posture
and the strongest production-grade KEK custody available
without bringing PKCS#11 into the binary.
Decrypts a previously-wrapped DEK using the cloud-side KEK. Returns the 32-byte plaintext DEK.
Authentication failure (wrong KEK, deleted KEK, network
auth refused) surfaces as ErrUnwrap:
return nil, fmt.Errorf("aws-kms: %w: %s", kms.ErrUnwrap, awsErr)Callers errors.Is(err, kms.ErrUnwrap) to distinguish
"wrong key" from "network error" from "key scheduled for
deletion".
The most consequential operation in the binary.
Schedules destruction of the cloud-side KEK. Cloud KMS
providers typically schedule deletion with a cool-off
window (AWS KMS: 7-30 days; configurable via the
provider's pending_window_days config key). After the
window elapses the key is destroyed; every backup whose
wrapped_dek depends on this KEK becomes
permanently unrecoverable — by design.
This is the GDPR Art. 17 / right-to-erasure primitive.
The CLI gates kms shred behind n-of-m approval +
typed-keyring confirmation + --yes; the audit chain
records the schedule + deletion-date for compliance.
ErrShredFailed wraps non-network failures. Cloud KMS
often refuses Shred with structured errors (key already
pending deletion, key in different account, key still has
active grants); the provider returns these wrapped:
return fmt.Errorf("aws-kms: %w: %s", kms.ErrShredFailed, awsErr)Reports whether this provider is operating in
FIPS-validated mode. Used by pg_hardstorage doctor to
surface compliance posture and by the runtime
--fips-strict flag to refuse non-FIPS providers.
For AWS KMS, this means the operator pointed at a
FIPS-validated region (us-gov-west-1, us-gov-east-1,
or any commercial region with aws_use_fips_endpoint=true
in the config). For Vault Transit, it means the Vault
deployment is running on FIPS-validated cryptographic
modules (Vault Enterprise + FIPS Inside).
FIPSMode() is a must-tell-the-truth method. Lying
returns true here under non-FIPS operation will land
backups in the FIPS audit trail under false pretences.
Release provider-side resources — HTTP connections, SDK clients, leased Vault tokens. Idempotent.
// in your provider's package
func init() {
kms.DefaultRegistry.Register("my-scheme",
func(ctx context.Context, kekRef string, cfg map[string]any) (kms.Provider, error) {
return New(ctx, kekRef, cfg)
})
}The Builder signature receives:
| Arg | Source |
|---|---|
ctx |
The caller's context (for SDK-init timeouts, AWS STS round-trips, …) |
kekRef |
The full manifest-stamped string (aws-kms://arn:aws:kms:...) |
cfg |
Provider-specific config from pg_hardstorage.yaml (region, aws_use_fips_endpoint, pending_window_days, etc.) |
The host's kms.DefaultRegistry.Open(ctx, kekRef, cfg)
extracts the scheme via SchemeOf(kekRef), looks up the
builder, calls it, and returns the ready-to-use Provider.
Re-registration is allowed and overwrites. This is
the idiom Tier-2 plugins use to override a Tier-1
default — pg_hardstorage calls
DefaultRegistry.Register("aws-kms", tier2Builder) after
the Tier-2 plugin discovery phase finishes, replacing the
in-tree implementation.
var (
ErrUnwrap = errors.New("kms: DEK unwrap failed")
ErrShredFailed = errors.New("kms: shred failed")
ErrUnknownScheme = errors.New("kms: unknown KEKRef scheme")
)Use errors.Is for detection. Wrap your provider
errors:
return nil, fmt.Errorf("vault-transit: %w: %s", kms.ErrUnwrap, vaultErr)| Operation | Concurrent calls allowed? |
|---|---|
WrapDEK / UnwrapDEK (different DEKs) |
Yes |
WrapDEK / UnwrapDEK (same DEK) |
Yes — KEK ops are stateless w.r.t. the wrapped value |
Shred |
Effectively single-call; subsequent ops will fail |
Close |
Serial; host serializes against in-flight ops |
Cloud KMS resolves over the public internet (or via VPC
endpoint with private IP). Operators running in air-gap
mode (PG_HARDSTORAGE_AIRGAPPED=1) MUST point at an
endpoint that resolves to an RFC 1918 address; the
air-gap policy honours the routable-private-IP allowlist.
Provider implementations consult airgap.Default() in
their constructor and refuse if the resolved endpoint
violates the policy — see awskms.go for the pattern.
UnwrapDEKfailures wrapErrUnwrap. Callers distinguish auth-failure from network-failure.KEKRef()is stable. A provider that mints a fresh KEK atOpenstamps the same string back intoKEKRef()so the manifest writer captures the correct reference.Shredis irreversible. Provider implementations MUST NOT silently downgrade to a soft-delete; if the backend doesn't support irreversible destruction, return an error.FIPSMode()doesn't lie. False positives here pollute the compliance audit trail.
The Tier-2 gRPC contract (see
proto/plugin/v1/plugin.proto service EncryptionPlugin)
exposes a slightly broader surface than kms.Provider:
the proto's GenerateDEK (mints a fresh DEK and returns
both plaintext and wrapped form) and RotateKEK
(re-wrap the same DEK under a new KEK) are convenience
operations the Tier-1 host implements outside the
Provider interface — the proto folds them in for
language-agnostic plugins.
A pure-kms.Provider Tier-2 plugin that doesn't
implement GenerateDEK / RotateKEK returns
UNIMPLEMENTED for those RPCs; the host falls back to
WrapDEK against a host-generated DEK.
- KEKRef catalogue:
reference/kekref-schemes.md(auto-generated fromkms.DefaultRegistry.Schemes()). R2 — KMS key destroyedrunbook: response procedure whenShredfired against the wrong key.R3 — Cold start from backupsrunbook: what unwrap failure looks like during a cold-DR drill.- The compliance posture pages
(
compliance/) on FIPS-strict mode and the audit chain.