Factor-bound aliases

Copy Markdown View Source

Use this model when a KMS alias must require a user-held secret cryptographically, not just an authorization binding. The same public KMS calls work locally and through KMS.Client.Remote.

A new alias created with session: uses session protection. Its creator can immediately encrypt, decrypt, rewrap, and manage its tags. A principal must have kek.create permission first. Existing aliases are never silently converted or claimed by another creator.

Two distinct protection modes

RMK-only aliasSession-protected alias
CreateKMS.create_alias(alias)KMS.create_alias(alias, session: secret)
Metadataprotection: "rmk"protection: "session"
Persisted KEKRMK-encrypted keyOne encrypted envelope per recipient; no RMK-only KEK copy
Principal accessBinding and active sessionBinding, active session, and successful recipient-key unwrap
No-session accessTrusted/admin accessFails; an admin token is not a decryption secret
Principal rewrapLegacy behavior: grant a decrypt bindingSeal a KEK copy to the recipient public key and grant a decrypt binding
Anonymous accessNot allowed through a passphrase sessionOnly with a passphrase that unwraps an existing envelope for this alias

Passing session: when accessing an existing RMK-only alias still enforces bindings, but does not turn it into a factor-bound alias. Passing session: to create_alias/2 for an existing RMK-only alias returns {:error, :unprotected_alias}. Use a new alias for this model.

What is encrypted with what?

For a principal recipient:

principal password --PBKDF2--> password-derived key
  + RMK --> unwrap principal private key at login
  + enabled factor flow --> authenticated session
session bearer secret + RMK --> unwrap session copy of principal private key
principal private key + RMK --> unwrap recipient envelope --> KEK
KEK --> unwrap DEK --> decrypt application ciphertext with AAD

Sharing uses only the recipient public key; the sender does not need the recipient's password or private key. A binding alone cannot manufacture an envelope or decrypt it. The sender needs kek.rewrap permission and must be able to unwrap the alias itself.

For an anonymous passphrase recipient:

high-entropy passphrase + random salt --HKDF--> wrapping key
wrapping key + RMK --> unwrap passphrase envelope --> same KEK
KEK --> same DEK --> same application ciphertext

The passphrase is exchanged once for a bearer session. Subsequent alias operations use that session, not the passphrase. On every operation, KMS looks up the current recipient envelopes and cryptographically unwraps one. A passphrase session is not a grant to all aliases.

New protected KEKs are random 256-bit keys. Principal envelopes use the existing ECDH/AES-GCM helper with a 256-bit symmetric key. Passphrase envelopes use the existing HKDF/AES-GCM primitives and require high-entropy secrets, not ordinary human passwords. Envelope version, wrapping id, KEK id, and recipient id are authenticated context. Each envelope is also RMK-encrypted.

The keks.master_key column is NULL for this mode; the migration makes it nullable on both databases. Key material lives only in kek_wrappings.

Complete public-API example

Run migrations first. The example expects the usual KMS runtime, database, RMK, and session configuration. Use an empty example database or unused principal references/alias names. It is exercised by test/kms/factor_bound_aliases_test.exs.

The initial principal and permission grants are trusted bootstrap operations. In a real application, provision these administratively, not from an anonymous controller. Password enrollment/delivery and all application authorization remain the host application's responsibility.

alice_password = KMS.Passphrase.generate()
bob_password = KMS.Passphrase.generate()

# Trusted bootstrap: create Alice and grant only the required creation rights.
{:ok, alice} =
  KMS.create_principal(%{
    kind: "users",
    ref: "model-alice",
    username: "model-alice",
    password: alice_password
  })

{:ok, _create_alias} =
  KMS.create_binding(alice.id, %{permission: "kek.create", scope: nil})

{:ok, _create_bob} =
  KMS.create_binding(alice.id, %{
    permission: "principal.create",
    scope: "principal.ref:users/model-bob"
  })

{:ok, alice_session} =
  KMS.create_session("users", "model-alice", %{password: alice_password})

# Alice creates an alias whose key is sealed to her public key.
{:ok, %{protection: "session"}} =
  KMS.create_alias("model/documents", session: alice_session.secret)

aad = "my-app:v1:document:123:body"

{:ok, encrypted} =
  KMS.encrypt_alias("document body", "model/documents",
    session: alice_session.secret,
    aad: aad
  )

{:ok, "document body"} =
  KMS.decrypt_alias(encrypted, "model/documents",
    session: alice_session.secret,
    aad: aad
  )

{:error, :unauthenticated} =
  KMS.decrypt_alias(encrypted, "model/documents", aad: aad)

# Alice creates Bob through her session, then seals a KEK copy to Bob.
{:ok, bob} =
  KMS.create_principal(
    %{kind: "users", ref: "model-bob", username: "model-bob", password: bob_password},
    session: alice_session.secret
  )

:ok =
  KMS.rewrap_alias_for_principal("model/documents", bob.kind, bob.ref,
    session: alice_session.secret
  )

{:ok, bob_session} =
  KMS.create_session(bob.kind, bob.ref, %{password: bob_password})

{:ok, "document body"} =
  KMS.decrypt_alias(encrypted, "model/documents", session: bob_session.secret, aad: aad)

# Sharing grants decrypt only, not permission to encrypt or share onward.
{:error, :not_allowed} =
  KMS.encrypt_alias("replacement", "model/documents", session: bob_session.secret, aad: aad)

# Alice also grants anonymous access through a high-entropy passphrase.
old_passphrase = KMS.Passphrase.generate()

{:ok, old_wrapping_id} =
  KMS.rewrap_alias_for_passphrase("model/documents", old_passphrase,
    session: alice_session.secret
  )

{:ok, old_session} = KMS.create_passphrase_session(%{passphrase: old_passphrase})

{:ok, "document body"} =
  KMS.decrypt_alias(encrypted, "model/documents", session: old_session.secret, aad: aad)

# Replace access: create the new envelope before revoking the old one.
new_passphrase = KMS.Passphrase.generate()

{:ok, _new_wrapping_id} =
  KMS.rewrap_alias_for_passphrase("model/documents", new_passphrase,
    session: alice_session.secret
  )

:ok =
  KMS.revoke_alias_passphrase("model/documents", old_wrapping_id,
    session: alice_session.secret
  )

# Even the already-issued old session now loses access.
{:error, :not_allowed} =
  KMS.decrypt_alias(encrypted, "model/documents", session: old_session.secret, aad: aad)

{:ok, new_session} = KMS.create_passphrase_session(%{passphrase: new_passphrase})

{:ok, "document body"} =
  KMS.decrypt_alias(encrypted, "model/documents", session: new_session.secret, aad: aad)

# Bob's independent envelope still works. The ciphertext and AAD never changed.
{:ok, "document body"} =
  KMS.decrypt_alias(encrypted, "model/documents", session: bob_session.secret, aad: aad)

:ok

Store each wrapping id with application-owned sharing metadata so you can revoke that grant later. The id is not the passphrase. Do not persist the passphrase next to the ciphertext or log the request body.

A passphrase envelope grants encrypt/decrypt access to its alias, not tag management, principal creation, or onward sharing. A passphrase session can also create a new session-protected alias; that alias has only the passphrase recipient unless offline recovery is configured. For administratively managed sharing and replacement, use the principal-created model above.

Factors and sessions

The principal password protects the persisted private key. Optional factors protect/challenge the session's private-key flow; pending sessions cannot use aliases. Complete the configured factor chain before passing the authenticated session to the operations above.

“Factors are part of the key material” means the caller must recover the recipient's private key, not that password or OTP bytes are concatenated into the KEK. The existing factor providers have different properties: TOTP uses an RMK-stored seed, and WebAuthn auth-only sessions do not carry a decryption key. Those auth-only sessions return :private_key_unavailable for protected alias operations. Do not claim that every factor independently survives KMS-host compromise. See Factors and Sessions.

The built-in one_time_code provider returns its code and is for tests/development only. Use a production factor provider and out-of-band enrollment/delivery.

Revocation, caching, and recovery limits

  • Revocation deletes one envelope, not a hash or a binding. Already-issued, nudged, and refreshed passphrase sessions must still find a current envelope on each request. A new session made from the revoked passphrase also fails to open the alias.
  • Multiple grants can use the same passphrase. Revoke all matching grants if the secret itself must lose access. Reusing the old secret for the replacement does not revoke knowledge of that secret.
  • Replacement is two explicit operations. Check that the new wrapping succeeded before revoking the old one; a brief overlap in access is expected. Operations already in flight may finish.
  • No plaintext protected KEK is put in the global RMK key cache. Only successfully RMK-unwrapped, still factor-encrypted envelope payloads are cached. Deletion is checked before that cache is used. Session/private-key payload caching retains its existing behavior.
  • Revocation cannot erase plaintext or keys already copied by an authorized caller, or invalidate old database backups containing the envelope. Protect and expire backups accordingly.
  • Configured offline fallback keys are an intentional alternate decryption path. Creation writes recovery envelopes inside the protected-alias transaction. Losing all recipient secrets without usable recovery loses the data.
  • The HTTP API remains a trusted backend/admin surface. The separate remote fallback API boundary work is tracked in GitLab #2; this feature does not make an exposed KMS admin token safe.

Compatibility and boundaries

This implements the generic model from GitLab #1, not an application-specific migration, password reset policy, delivery system, or upload adapter.

Existing no-session RMK-only workflows and ciphertext formats stay unchanged. Legacy rewrap_alias_for_principal/4 on an RMK-only alias still grants a binding only. For a strong user-held-secret boundary, move data deliberately to a new session-protected alias; adding a binding cannot remove the old RMK-only copy or historical backups.

KMS.Keks.rotate_kek/2 rejects session-protected aliases rather than downgrading them to RMK-only keys. Data-key rotation for this mode is not exposed by this change; passphrase envelope replacement is not KEK rotation. Principal password changes/resets are also a separate operation and must not be used as an alias-passphrase replacement mechanism.

See Migrations and upgrades before deploying or rolling back.