aaieduhr/aead-envelope

Versioned AEAD envelope for encrypting short values at rest, with a key ring and canonical additional authenticated data.

Maintainers

Package info

gitlab.opencode.hr/srce/aai-eduhr/aead-envelope

pkg:composer/aaieduhr/aead-envelope

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

v1.0.0 2026-08-26 11:57 UTC

This package is auto-updated.

Last update: 2026-08-26 14:42:26 UTC


README

A versioned AEAD envelope for encrypting short values at rest, with a key ring for rotation and canonical additional authenticated data for binding a ciphertext to where it is stored.

It is not a general-purpose file or stream cipher.

  • XChaCha20-Poly1305 through ext-sodium. The 24-byte nonce is wide enough that a random nonce per value never approaches a repeat, so nothing has to keep a counter.
  • Self-describing envelope carrying a format version and a key id, so key rotation never has to be an all-at-once operation and the format can change without guesswork.
  • Fail-closed everywhere. Nothing in this package returns the input, an empty string, or any other stand-in when decryption fails.
  • No framework. Two applications on different stacks can share one format and one set of test vectors.

Requirements

PHP 8.4, ext-sodium.

Install

composer require aaieduhr/aead-envelope

Use

use AaiEduHr\AeadEnvelope\Aad;
use AaiEduHr\AeadEnvelope\Cipher;
use AaiEduHr\AeadEnvelope\KeyRing;

// Master secrets come from configuration. Generate one with:
//   php -r 'echo base64_encode(random_bytes(32)), PHP_EOL;'
$keyRing = KeyRing::fromBase64(
    ['1' => $retiredKeyBase64, '2' => $currentKeyBase64],
    activeKid: '2',
);

$cipher = new Cipher($keyRing);

// The context the ciphertext is bound to. Anything that identifies the row it belongs in.
$aad = Aad::build('user-secrets:v1', $userId, $secretKind);

$envelope = $cipher->encrypt($secret, $aad);
// AEV1.2.gVKp…9wQ.7mLd…KdT7

$secret = $cipher->decrypt($envelope, $aad);

Construct the key ring once, at start-up. It validates its own contents, so a misconfigured deployment fails when it boots rather than the first time someone tries to log in.

Envelope format

AEV1.<kid>.<base64url(nonce)>.<base64url(ciphertext ‖ tag)>
 │     │           │                      │
 │     │           │                      └─ Poly1305 tag is the last 16 bytes
 │     │           └─ exactly 24 bytes
 │     └─ key id, /\A[A-Za-z0-9_-]{1,16}\z/
 └─ format version

Base64url is unpadded and canonical: decoding rejects any spelling that does not re-encode to itself. Without that check an envelope would have several valid forms, and anything comparing envelopes as strings would be fooled by the difference.

decrypt() rejects an envelope that does not have exactly four fields, names an unrecognised version or an unknown key, carries a nonce of the wrong length, carries a ciphertext shorter than the tag, or fails to authenticate under the given key and context.

Additional authenticated data

Aad::build() produces a length-prefixed encoding:

aev:v1|<len>:<domain>|<len>:<part>|<len>:<part>…

Lengths are byte lengths of the exact strings given, with no Unicode normalisation. The prefixing is what makes the encoding unambiguous: under plain concatenation ("ab", "c") and ("a", "bc") would produce one string, and a ciphertext bound to one context would open under the other.

Pass the domain as a constant of your own, one per storage location. Do not derive it from something that can change at run time — a table name read from a model, say. If it changes, every existing envelope becomes undecryptable.

What AAD does: stops a ciphertext being moved into another row and read there. What it does not do: stop deletion, stop denial of service, or stop an attacker with write access replaying an older ciphertext into the same row. Freshness is outside what an AEAD can give you.

Key rotation

A key stays in the ring for as long as any value encrypted under it still exists — in the store, in a backup, in a replica, or in a long-running process that has not restarted. Rotation is therefore additive:

  1. Add the new master secret to the ring in every application that reads these values.
  2. Move activeKid to the new id. New values are encrypted under it; old ones still read.
  3. Re-encrypt existing values at your own pace. Cipher::kidOf() reports which key an envelope uses without decrypting it, so a pass can find what is left.
  4. Remove the old secret only once nothing uses it.

Master secrets are never used directly. Each is run through HKDF-SHA256 with the context aev:key-enc:v1 to produce the key handed to the cipher.

What this package deliberately does not do

There is no isEnvelope(). The AEV1 prefix marks a format version; it is not evidence that a value is ciphertext, because a plaintext value beginning with AEV1. is possible wherever the source data is not tightly constrained. Decide from where the value came — a dedicated column that is null when absent — not from how it starts.

There is no fallback to plaintext. A caller that treats a decryption failure as "this value was never encrypted" hands an attacker who can corrupt stored ciphertext a way to switch the protection off. Every failure is an exception.

The key ring cannot be serialised. Serialising it would write master secrets into a session, a cache entry or a queue payload, all with weaker protection than the configuration they came from. var_dump() and print_r() show a redacted form for the same reason.

Test vectors

vectors/aev1.json holds the shared vectors: AAD encodings, envelopes that must decrypt, and envelopes that must be rejected. Every implementation of this format should run them. Two implementations drift apart quietly otherwise — each stays self-consistent, and the disagreement surfaces the first time one tries to read what the other wrote.

The vectors are generated from primitives rather than from this package's own classes, so they test the implementation against the specification instead of against itself:

php tools/generate-vectors.php

The master keys in that file are fixed test values. They protect nothing and must never be deployed.

Development

composer pre-commit

Runs phpcbf, phpcs, psalm and phpunit.

Licence

EUPL-1.2