Direct or envelope encryption
A rypt key can be used in two ways. In direct mode you send the data itself to encrypt
and decrypt. In envelope mode you encrypt the data in your own code under a data key,
and rypt only wraps and unwraps that data key with wrap and unwrap. rewrap works
in both modes.
Which to choose
Section titled "Which to choose"Use direct mode when each value is small (at most 65,536 bytes, or 8,176 bytes together
with its aad on a Hardware (hsm) key), you want no cryptography code in your
application, and you accept that the value passes through rypt on every call: database
fields, tokens and secrets, for example.
Use envelope mode when values are larger than that, when the data itself should not leave your systems, or when you want the number of calls to stay the same whatever the size of the data. Your data does not reach rypt, but the data key does, so this is not zero-knowledge.
Direct: encrypt, decrypt |
Envelope: wrap, unwrap |
|
|---|---|---|
| What reaches rypt | The whole value, in the encrypt request and the decrypt response, and its aad. On Software (software) and Hardware keys, Cloud KMS sees both as well. |
The data key (16, 24 or 32 bytes), its wrapped form and its aad. On Software and Hardware keys, Cloud KMS sees the data key and the aad as well. Your data does not reach rypt. |
| Size per call | 1 to 65,536 bytes of plaintext. On a Hardware key, plaintext and aad together at most 8,176 bytes. | A data key of 16, 24 or 32 bytes. On a Hardware key, data key and aad together at most 8,176 bytes. rypt puts no limit on your data. |
| Operations per item | One encrypt to write and one decrypt per read. A larger value has to be split by you: 1 MiB is 16 encrypt calls, and 16 decrypt calls to read it back, or at least 129 of each on a Hardware key. |
One wrap per data key and one unwrap per read, whatever the size of the data. Keeping an unwrapped data key in memory saves the unwrap calls. |
| Per call | One HTTPS request, carrying the value in base64, which is a third larger than the value. On Software and Hardware keys, rypt also makes one Cloud KMS call. | One HTTPS request, carrying only the data key, its wrapped form and the aad. On Software and Hardware keys, rypt also makes one Cloud KMS call. Your data is encrypted and decrypted in your process. |
| Your code | HTTP and base64. | HTTP, base64, and an AEAD cipher such as AES-GCM, with its nonces. |
| What you store | The ciphertext, and the aad or a way to rebuild it. | Your ciphertext, its nonce, the wrapped data key, and the aad or a way to rebuild it. |
| Moving to the newest key version (Software and Hardware keys) | rewrap each ciphertext, one operation each. |
rewrap each wrapped data key, one operation each. The data is not touched. |
Every successful call is one operation, whatever its size, and there is no batch call:
one value or one data key per request. Reads count as well as writes: a Free (free)
key's 10,000 operations a month include every decrypt and unwrap, and once they are
used up those are refused too, as
the Free tier's monthly cap explains. Sizes are counted after base64 decoding, but the whole JSON body is limited to
128 KiB, so the largest plaintext and the largest aad do not fit in one request
together. The limits page has every size rule.
Requests
Section titled "Requests"Every call on this page is a POST to /v1/keys/{id}/<operation> with a JSON body
(Content-Type: application/json) and an API key; a dashboard session gets
403 api_key_required. Binary fields are standard base64 with padding, in requests and
responses, and URL-safe or unpadded base64 is 400 invalid_base64. aad is optional in
every call: absent, null and "" all mean none, and opening a value needs the same aad
that made it. Additional authenticated data
covers what to put in it.
Direct mode
Section titled "Direct mode"encrypt
Section titled "encrypt"curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/encrypt \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"plaintext": "aGVsbG8gd29ybGQ=", "aad": "dXNlcj00MjtmaWVsZD1lbWFpbA=="}'
Here plaintext is hello world and aad is user=42;field=email. The response holds
the ciphertext and nothing else:
{"ciphertext":"...base64..."}
The ciphertext names the key version it was made under, so decrypt keeps working after
a rotation for as long as the key retains that version. An empty
plaintext is 400 invalid_request, and one over 65,536 bytes is
413 payload_too_large.
decrypt
Section titled "decrypt"curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/decrypt \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ciphertext": "...base64...", "aad": "dXNlcj00MjtmaWVsZD1lbWFpbA=="}'
{"plaintext":"aGVsbG8gd29ybGQ="}
A different aad and an altered ciphertext get the same 400 invalid_ciphertext, so the
error does not tell you which one it was.
Envelope mode
Section titled "Envelope mode"To write: call wrap for a data key and its wrapped form, encrypt your data with the
data key, store the wrapped form next to the ciphertext, and discard the data key. To
read: send the wrapped form to unwrap and decrypt with the data key it returns. rypt
stores neither the data key nor the wrapped form, so if you lose wrapped, the data
under it cannot be decrypted.
wrap with a generated data key
Section titled "wrap with a generated data key"Send no dek, and rypt generates one. The body must still be JSON: send {} when you
have no aad.
curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/wrap \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"aad": "ZG9jPTQy"}'
Here aad is doc=42. The response carries the data key and its wrapped form:
{"dek":"xmgMTCc2rWSkCnn/4Fw4nLGe+LvSa0GSu8WRD6ETCrw=","wrapped":"...base64..."}
dek is 32 bytes from a cryptographically secure random generator, a new one on every
call, for AES-256. There is no variant that generates a data key and returns only
wrapped.
"dek": null also asks rypt to generate one. "dek": "" does not: it decodes to zero
bytes and is refused with 400 invalid_dek_length:
{"error":"invalid_dek_length","message":"dek must be 16, 24 or 32 bytes","request_id":"..."}
wrap your own data key
Section titled "wrap your own data key"curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/wrap \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dek": "Xozp6oyWu1zStM5q+wFVmprrXaIsN0bl0+S2RWw0wV4=", "aad": "ZG9jPTQy"}'
dek must decode to 16, 24 or 32 bytes (an AES-128, AES-192 or AES-256 key); any other
length is 400 invalid_dek_length. Generate it with a cryptographically secure random
source, for example openssl rand -base64 32. The response has no dek field at all,
not even null:
{"wrapped":"...base64..."}
rypt still sees this data key in plaintext while it wraps it.
unwrap
Section titled "unwrap"curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/unwrap \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"wrapped": "...base64...", "aad": "ZG9jPTQy"}'
{"dek":"xmgMTCc2rWSkCnn/4Fw4nLGe+LvSa0GSu8WRD6ETCrw="}
aad must be the one sent to wrap. As with decrypt, a mismatch is
400 invalid_ciphertext.
A wrapped data key and a ciphertext from encrypt are the same format, and decrypt
and unwrap each accept the other's; for an API key's actions that has a consequence,
under what actions do not restrict.
A complete example in Python
Section titled "A complete example in Python"This uses requests and the cryptography package's AESGCM. Each record is encrypted
locally under its own data key from rypt, with a random 12-byte nonce. The same context
bytes are the aad at rypt and in AES-GCM, so a record copied to another context fails to
open.
import base64
import os
import requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
KEY_URL = f"https://api.rypt.dev/v1/keys/{os.environ['KEY_ID']}"
AUTH = {"Authorization": f"Bearer {os.environ['RYPT_TOKEN']}"}
def rypt(operation, body):
url = f"{KEY_URL}/{operation}"
response = requests.post(url, json=body, headers=AUTH, timeout=30)
response.raise_for_status()
return response.json()
def b64(data):
return base64.b64encode(data).decode()
def seal(data, context):
keys = rypt("wrap", {"aad": b64(context)}) # rypt generates a 32-byte data key
dek = base64.b64decode(keys["dek"])
nonce = os.urandom(12)
ciphertext = AESGCM(dek).encrypt(nonce, data, context)
# Store these three together. Never store the data key.
return {"wrapped": keys["wrapped"], "nonce": nonce, "ciphertext": ciphertext}
def unseal(record, context):
unwrapped = rypt("unwrap", {"wrapped": record["wrapped"], "aad": b64(context)})
dek = base64.b64decode(unwrapped["dek"])
return AESGCM(dek).decrypt(record["nonce"], record["ciphertext"], context)
record = seal(b"quarterly figures", b"doc=42")
assert unseal(record, b"doc=42") == b"quarterly figures"
That is one wrap per record written and one unwrap per record read. If you reuse a
data key for several records, use a new random nonce for every encryption: a nonce must
never repeat under the same key.
rewrap
Section titled "rewrap"rewrap decrypts a ciphertext under whichever version of the key made it and encrypts
it again under the key's current primary version, in one call. The plaintext is not
returned to you, but it is decrypted: on Software and Hardware keys it passes between
rypt and Cloud KMS twice, as a decrypt followed by an encrypt would. On Software and
Hardware keys, rewrap is how you move ciphertext, or wrapped data keys, off an older
version before a rotation retires it.
curl -sS https://api.rypt.dev/v1/keys/$KEY_ID/rewrap \
-H "Authorization: Bearer $RYPT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ciphertext": "...base64...", "aad": "ZG9jPTQy"}'
{"ciphertext":"...base64..."}
Replace the stored value with the new one. For a wrapped data key, send the wrapped
value as ciphertext and store the returned ciphertext as the new wrapped: unwrap
accepts it, and the data encrypted under that data key does not change.
- It keeps the aad. The same
aadis used to decrypt and to encrypt again, and there is no field for a new one: an extra field such asnew_aadis ignored, not rejected. To change the aad,decryptandencryptagain, orunwrapandwrapthe same data key: two operations, and the plaintext or data key comes back to you. - It always re-encrypts and always costs one operation, even when the ciphertext is already under the primary version. No call reports which version a ciphertext is under.
- It works within one key. No call moves ciphertext to another key:
decryptandencryptunder the other key instead, orunwrapthe data key andwrapit there. - It makes new ciphertext, so on a paid key whose billing is not in good standing it
is
402 billing_required, likeencryptandwrap.decryptandunwrapstill work. - It cannot rescue Free or Extra wrapped (
wrapped) keys from a rotation. They keep one version, so the versionrewrapencrypts under is the one the rotation retires. Rotation explains what to do instead.