Files
crypto/docs/symmetric/deterministic-aead.mdx
T
2026-09-02 15:29:51 -04:00

197 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Deterministic AEAD (AES-SIV)
description: The daed package — AES-SIV-CMAC per RFC 5297 with a mandatory 64-byte key. Nonce-free and misuse-resistant, at the price of leaking plaintext equality.
sidebar:
order: 3
icon: repeat
---
`github.com/sonr-io/crypto/daed` implements AES-SIV-CMAC as specified in [RFC 5297](https://tools.ietf.org/html/rfc5297). "DAED" is deterministic authenticated encryption with associated data: same key, same plaintext, same associated data, byte-identical ciphertext, every time. There is no nonce parameter and nothing to keep unique.
The implementation is a port of Tink's `subtle` AES-SIV — the test file even aliases the import as `subtle` and loads Wycheproof vectors — and it is restricted to a **single** associated-data component, unlike the general SIV construction which takes a vector of headers.
## The trade
Randomized AEAD like [`aead`](/symmetric/aead) hides everything, but only because a fresh nonce makes every ciphertext unique. That safety is contingent: repeat the nonce once and AES-GCM collapses. AES-SIV removes the nonce entirely by deriving the IV from the message itself (the S2V PRF over the associated data and the plaintext), then running AES-CTR with that IV.
What you get:
- **Nothing to keep unique.** No nonce store, no counter, no rand call on the encrypt path.
- **Misuse resistance.** There is no parameter you can repeat to break it.
- **Stable ciphertext.** You can index it, dedupe it, or use it as a lookup key.
What you pay:
- **Plaintext equality leaks.** Two records that encrypt to the same bytes had the same plaintext and the same associated data. An observer learns that without touching the key.
:::warning[Deterministic means equality is public]
If you deterministically encrypt an email address column, anyone with read access to the ciphertexts can count distinct users, spot duplicate accounts, join across tables, and — with a guessable domain — confirm a guess by encrypting a candidate and comparing. Deterministic encryption is *searchable*, and searchable is the same thing as *leaky*. Vary the associated data per row (a row ID, a tenant ID) when you want equality confined to a scope, and reach for randomized [`aead`](/symmetric/aead) whenever equality itself is sensitive.
:::
## When to use it
Good fits:
- **Wrapping key material.** Encrypting a data key under a key-encryption key, where there is no room in the format for a nonce and no natural place to store one.
- **Deterministic encryption of identifiers.** An opaque token or blind index that you must still be able to look up by equality.
- **Dedupe-able ciphertext.** Content-addressed storage where identical inputs should collapse to one object.
- **Protocols that give you no nonce channel.** Fixed-width fields, legacy record formats, anything where the only bytes you control are the ciphertext.
Bad fits: message payloads, session data, anything user-visible and repeated, and anything where two equal plaintexts appearing twice would be a disclosure.
## Constants and key size
| Constant | Value | Meaning |
| --- | --- | --- |
| `daed.AESSIVKeySize` | `64` | The **only** accepted key length: 512 bits |
The 64-byte key is a double-length key and it is not padding. `NewAESSIV` splits it as `K1 = key[:32]` (the CMAC/S2V key, used to build the AES cipher for the PRF) and `K2 = key[32:]` (the CTR encryption key). RFC 5297 requires the MAC and encryption keys to be the same size, so a 256-bit security level means 2 × 256 bits of key material.
The package's doc comment explains *why* 64 and not 32, and this is the one place the source names a paper, so it is worth repeating verbatim in substance: Chatterjee, Menezes and Sarkar's tightness analysis (Section 5.1) shows AES-SIV is attackable in the multi-user setting — given the encryption of one message under `k` different keys, a MAC key can be recovered in time `2^b / k` for MAC-key size `b`. That makes 128-bit MAC keys insufficient, and since 192-bit AES keys are not supported, the key must be 2 × 256 bits.
`NewAESSIV` rejects every other length with `aes_siv: invalid key size N` — the package's `TestAESSIV_KeySizes` walks every prefix length from 0 to 300+ and asserts that exactly 64 is accepted.
## Ciphertext layout
```text
EncryptDeterministically output:
┌────────────────────┬───────────────────────────┐
│ SIV / tag 16 bytes │ ciphertext len(plaintext) │
└────────────────────┴───────────────────────────┘
= S2V(plaintext, ad) = AES-CTR(K2, masked SIV)
```
The synthetic IV goes **first** and doubles as the authentication tag. Output length is always `len(plaintext) + 16`. An empty plaintext produces a 16-byte ciphertext, and `DecryptDeterministically` rejects anything shorter than 16 bytes with `aes_siv: ciphertext is too short`.
Decryption decrypts first, then recomputes S2V over the recovered plaintext and compares it to the stored SIV byte-by-byte with an accumulating XOR. A mismatch returns `aes_siv: invalid ciphertext`.
## Usage
Grounded in `daed/aes_siv_test.go` (`TestAESSIV_EncryptDecrypt`):
```go deterministic.go
package main
import (
"bytes"
"encoding/hex"
"fmt"
"github.com/sonr-io/crypto/daed"
)
func main() {
// 64 bytes = AESSIVKeySize. Two 32-byte halves: CMAC key, then CTR key.
keyStr := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
"00112233445566778899aabbccddeefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"
key, err := hex.DecodeString(keyStr)
if err != nil {
panic(err)
}
a, err := daed.NewAESSIV(key)
if err != nil {
panic(err)
}
msg := []byte("Some data to encrypt.")
ad := []byte("Additional data")
ct, err := a.EncryptDeterministically(msg, ad)
if err != nil {
panic(err)
}
fmt.Println(len(ct) == len(msg)+16) // true: SIV || ciphertext
// Determinism: identical inputs, identical output.
again, _ := a.EncryptDeterministically(msg, ad)
fmt.Println(bytes.Equal(ct, again)) // true
// Changing only the associated data changes the whole ciphertext.
other, _ := a.EncryptDeterministically(msg, []byte("Different data"))
fmt.Println(bytes.Equal(ct, other)) // false
pt, err := a.DecryptDeterministically(ct, ad)
if err != nil {
panic(err) // "aes_siv: invalid ciphertext"
}
fmt.Printf("%s\n", pt)
}
```
Note the associated data is a full input to the PRF, not a side channel: it changes the SIV and therefore the CTR keystream, so the entire ciphertext changes. Using a per-row identifier as associated data is the standard way to scope the equality leak.
## API reference
<TypeTable
type={{
"NewAESSIV(key []byte)": {
type: "(*AESSIV, error)",
required: true,
description: "Constructor. Errors unless len(key) == 64. Splits the key, builds the AES cipher over K1, and precomputes the two CMAC subkeys."
},
"EncryptDeterministically(plaintext, associatedData []byte)": {
type: "([]byte, error)",
required: true,
description: "Returns SIV(16) || ciphertext. Accepts nil and empty plaintext and nil associated data. Errors only if the plaintext is within one AES block of max int."
},
"DecryptDeterministically(ciphertext, associatedData []byte)": {
type: "([]byte, error)",
required: true,
description: "Errors on ciphertext shorter than 16 bytes, or when the recomputed SIV does not match the stored one."
}
}}
/>
`AESSIV` exposes its internals as exported struct fields:
<TypeTable
type={{
Cipher: { type: "cipher.Block", description: "AES cipher instance built over K1; used by the CMAC/S2V path." },
K1: { type: "[]byte", description: "First 32 bytes of the key — the CMAC/S2V key." },
K2: { type: "[]byte", description: "Last 32 bytes of the key — the AES-CTR encryption key." },
CmacK1: { type: "[]byte", description: "Precomputed CMAC subkey K1 (one GF(2^128) doubling of E(0))." },
CmacK2: { type: "[]byte", description: "Precomputed CMAC subkey K2 (a second doubling)." }
}}
/>
## Caveats
:::danger[The AESSIV struct publishes raw key material]
`K1`, `K2`, `CmacK1`, and `CmacK2` are exported fields holding the actual key bytes, and they are aliases into the slice you passed to `NewAESSIV` — not copies. Anything that can see an `*AESSIV` can read the key, and anything that mutates the slice you handed in mutates the cipher's key underneath it. Two consequences:
- Never log, marshal, `fmt.Printf("%+v")`, or serialize an `AESSIV` value. `%v` on this struct prints your key.
- Do not reuse or zero the input key slice while the `*AESSIV` is still in use. Conversely, `secure.Zeroize` on that slice *will* silently corrupt the live cipher.
Treat these fields as private even though the compiler will not.
:::
:::warning[Decrypt discards an internal error]
`DecryptDeterministically` calls `asc.ctrCrypt(...)` without checking its returned error — the only other call site, in `EncryptDeterministically`, does check it. In practice `ctrCrypt` can only fail if `aes.NewCipher(K2)` fails, which cannot happen for a key that already passed the 64-byte check in the constructor, so this is latent rather than exploitable. It is still an unchecked error on a decryption path, and the plaintext buffer would be returned uninitialized if it ever did fire. Always verify the returned error *and* validate the plaintext against your own schema.
:::
:::warning[No nonce is not the same as no risk]
AES-SIV is misuse-resistant, not misuse-proof. It removes nonce management, but it does not remove key management: the multi-user attack quoted above is precisely a "one message, many keys" attack, so avoid encrypting a known fixed plaintext under a large fleet of independent keys. And nothing about determinism protects against replay — a stored ciphertext is valid forever, so bind freshness into the associated data if you need it.
:::
:::note[Wycheproof vectors are skipped by default]
`TestAESSIV_WycheproofVectors` calls `t.Skip` unless the `TEST_SRCDIR` environment variable is set, pointing at a Bazel-style test data tree. In an ordinary `go test ./daed/...` run the cross-implementation vectors do not execute; only the round-trip and size tests do. Do not read a passing local test run as confirmation of RFC 5297 conformance.
:::
## Related
<CardGroup cols={2}>
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
AES-256-GCM: the default when equality of plaintexts must stay hidden.
</Card>
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
Producing the 64 bytes AES-SIV needs — ask HKDF for a 64-byte tag.
</Card>
</CardGroup>
To build a 64-byte AES-SIV key from one master secret, ask HKDF for 64 bytes rather than concatenating two independent 32-byte derivations:
```go
kek, err := subtle.ComputeHKDF("SHA256", master, salt, []byte("aes-siv key v1"), daed.AESSIVKeySize)
```