ecfg
- JSON, YAML, or TOML file with asymmetric-key-encrypted values
An ecfg
file is syntactically a json
, yaml
, or toml
file, but with a few minor semantic additions described below.
Each ecfg
file must have a key at the top level named
_public_key
. This implies that the top-level structure must be a
hashmap, not an array.
The _public_key
key must have a string value, which is a hex-encoded
32-byte (totalling 64 ASCII bytes) public key as generated by
ecfg-keygen(1).
By convention, _public_key
should be the first key in the file.
A value is considered encryptable if:
It is a string literal (numbers, true, false, null all remain unencrypted);
It is not an object key (ie. not immediately followed by a ":" in JSON, etc.);
Its corresponding object key did not begin with an underscore ("_").
Take special note of point 3. This is the reason _public_key
isn't
encrypted, and can be used to construct metadata schemes. For example, in the
excerpt below, only the values for rotation_password
and secret
will
be encrypted.
"my_secret": {
"_description": "API key for foocorp",
"_rotation": "https://example.com/foocorp/apikey",
"_rotation_username": "admin",
"rotation_password": "password",
"secret": "123123123123123123123"
}
Also note that this underscore "unencryptable" attribute is not heritable. For
example, the password in this excerpt will
be encrypted.
"_unencryptable": {
"password": "encrypted anyway"
}
When a value is encrypted, it will be replaced by a relatively long string of the form "EJ[V:P:N:M]". The fields are:
V
(decimal-as-string int)
Schema Version, hard-coded to "1" for now
P
(base64-encoded 32-byte array)
Public key of an ephemeral keypair used to encrypt this key
N
(base64-encoded 24-byte array)
Nonce used to encrypt this key
M
(base64-encoded variable-length array)
Raw ciphertext
ecfg
values are encrypted using a Curve25519 x Salsa20 x Poly1305-AES
public-key scheme. This normally implies use of NaCl
or libsodium
.
NaCl libraries generally take keys as a sequence of raw bytes, but they're embedded in ecfg files as hex-encoded strings, so we need a routine to convert them:
base16_to_raw(key : string) -> []byte =
# convert e.g. "1234beef" into [0x12, 0x34, 0xBE, 0xEF] or whatever
# the particular NaCl implementation/binding wants.
When we write the final encrypted message according to the SECRET SCHEMA
section, we need to encode several sequences of raw bytes to base64, with
newlines removed:
encode(raw : []byte) -> string =
sub("\n", "", base64_encode(raw))
Building the message given the ciphertext and other input parameters is just string concatenation:
format(pub : []byte, nonce : []byte, ct : []byte) -> string =
"EJ[1:" + encode(pub) + ":" + encode(nonce) + ":" + encode(ct) + "]"
During encryption, an ephemeral keypair is generated and the public key is embedded in the encrypted message.
The final encryption routine combines accepts a plaintext string and a
hex-encoded public key extracted from the input document, returning a formatted
ecfg
message. The NaCl API calls here are loosely paraphrased.
encrypt(plaintext : string, peer_pub_hex : string) -> string =
peer_pub = base16_to_raw(peer_pub_hex)
(ephemeral_pub, ephemeral_priv) = NACL.crypto_box_keypair()
# 24 random bytes
nonce = NACL.randombytes(NACL.NONCE_BYTES)
# API here varies a lot depending on binding.
ciphertext = NACL.crypto_box(
plaintext,
ephemeral_priv, peer_pub, nonce
)
format(ephemeral_pub, nonce, ciphertext)
If multiple values are being encrypted at once, a single ephemeral keypair may be reused. It may make sense but is by no means necessary to use box precomputation if it's available.
To decrypt messages from a document, the caller must first retrieve the private key associated to the public key embedded in the document, then the message must be decomposed into the three encoded values. This is just the inverse of the process from the encryption section above: remove the "EJ[]" enclosure; split the message on ":", check that the version is 1, then base64-decode the remaining three components.
Given those three components (peer_pubkey
, nonce
, and ciphertext
), the
decryption routine looks like:
decrypt(
target_privkey : string,
peer_pubkey : []byte,
nonce : []byte,
ciphertext : []byte,
) -> string =
priv = base16_to_raw(target_privkey)
# like above, this API varies a lot by binding implementation.
NACL.crypto_box_open(
priv, peer_pubkey, nonce, ciphertext
)
ecfg(1), ecfg-encrypt(1), ecfg-decrypt(1), ecfg-keygen(1)