mirror of
https://github.com/sonr-io/crypto.git
synced 2026-09-17 01:26:23 +00:00
feat: init docs
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Randomized AEAD (AES-256-GCM)
|
||||
description: The aead package — AES-256-GCM with a self-generated nonce prepended to every ciphertext, plus the exact key size, tag size, and wire layout.
|
||||
sidebar:
|
||||
order: 2
|
||||
icon: lock-keyhole
|
||||
---
|
||||
|
||||
`github.com/sonr-io/crypto/aead` is a thin, opinionated wrapper over the standard library's `crypto/cipher.NewGCM`. It exists to remove the two decisions people get wrong with raw AES-GCM: it fixes the key size at AES-256 and it generates and transports the nonce for you. The package doc comment cites NIST SP 800-38D.
|
||||
|
||||
There is exactly one type, `AESGCMCipher`, and one constructor. There is no AES-128 mode, no key-unwrapping helper, no streaming interface, and no algorithm identifier on the wire.
|
||||
|
||||
## When to use it
|
||||
|
||||
Reach for `aead` whenever you have a 32-byte symmetric key and some bytes to protect: session payloads, encrypted records, wrapped blobs, anything where you want confidentiality plus integrity and you can afford a fresh random nonce per message.
|
||||
|
||||
Do **not** use it when:
|
||||
|
||||
- You need the ciphertext to be a stable function of the plaintext (e.g. an encrypted database column you still have to query by equality). Use [`daed`](/symmetric/deterministic-aead) instead.
|
||||
- You will encrypt an enormous number of messages under a single key. A random 96-bit nonce is subject to the birthday bound, so nonce collisions become non-negligible after roughly 2^32 messages; rotate keys long before that.
|
||||
- You need to encrypt a stream too large to hold in memory. `Encrypt` and `Decrypt` are one-shot over full slices.
|
||||
|
||||
## Constants
|
||||
|
||||
All three constants are plain `int` literals in `aes_gcm.go`.
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `aead.NonceSize` | `12` | 96-bit GCM nonce — the size GCM is fastest with, and the only size accepted |
|
||||
| `aead.TagSize` | `16` | 128-bit GCM authentication tag |
|
||||
| `aead.KeySize` | `32` | AES-256 key size, and the **only** accepted key length |
|
||||
|
||||
:::info[32 bytes or nothing]
|
||||
`NewAESGCM` compares `len(key) != KeySize` and returns `invalid key size: expected 32 bytes, got N` for anything else. A 16-byte AES-128 key and a 24-byte AES-192 key are both rejected, which the package's own table-driven test asserts explicitly. This is stricter than `aes.NewCipher`, which would happily accept both.
|
||||
:::
|
||||
|
||||
## Ciphertext layout
|
||||
|
||||
This is the single most important fact about the package:
|
||||
|
||||
```text
|
||||
Encrypt / EncryptWithNonce output:
|
||||
┌────────────────┬──────────────────────────┬──────────────────┐
|
||||
│ nonce 12 bytes │ ciphertext len(plaintext)│ GCM tag 16 bytes │
|
||||
└────────────────┴──────────────────────────┴──────────────────┘
|
||||
└── produced by gcm.Seal(nil, nonce, pt, aad) ┘
|
||||
```
|
||||
|
||||
`Encrypt` generates the nonce itself from `crypto/rand` and **prepends** it to the sealed output. So:
|
||||
|
||||
- `len(output) == NonceSize + len(plaintext) + TagSize` — the test asserts exactly this.
|
||||
- `Decrypt` expects that same layout. It slices `data[:12]` as the nonce and passes `data[12:]` (ciphertext *and* tag) to `gcm.Open`. You never manage nonces yourself, and you never store them separately.
|
||||
- The minimum valid ciphertext is `NonceSize + TagSize` = 28 bytes (an empty plaintext). Shorter input returns `invalid ciphertext length: minimum 28 bytes required` before any crypto runs.
|
||||
|
||||
The AAD is **not** part of the output. Whatever you pass as `aad` must be reproducible at decrypt time from context you already have — a record ID, a version tag, a tenant name.
|
||||
|
||||
## Usage
|
||||
|
||||
Grounded in `aead/aes_gcm_test.go` (`TestAESGCMEncryptDecrypt`):
|
||||
|
||||
```go encrypt.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/aead"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// AES-256 key: exactly aead.KeySize bytes.
|
||||
key := make([]byte, aead.KeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
cipher, err := aead.NewAESGCM(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
plaintext := []byte("secret data")
|
||||
aad := []byte("additional auth data") // authenticated, not encrypted, not stored
|
||||
|
||||
// Encrypt returns nonce || ciphertext || tag.
|
||||
ct, err := cipher.Encrypt(plaintext, aad)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(len(ct) == aead.NonceSize+len(plaintext)+aead.TagSize) // true
|
||||
|
||||
// Decrypt takes that whole blob back, plus the identical AAD.
|
||||
pt, err := cipher.Decrypt(ct, aad)
|
||||
if err != nil {
|
||||
panic(err) // "decryption and authentication failed: ..."
|
||||
}
|
||||
fmt.Printf("%s\n", pt) // secret data
|
||||
}
|
||||
```
|
||||
|
||||
One `AESGCMCipher` value can be reused for many messages — the underlying `cipher.AEAD` is stateless and safe for concurrent use, and each `Encrypt` draws a fresh nonce.
|
||||
|
||||
## API reference
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
"NewAESGCM(key []byte)": {
|
||||
type: "(*AESGCMCipher, error)",
|
||||
required: true,
|
||||
description: "Constructor. Errors unless len(key) == 32. Wraps aes.NewCipher then cipher.NewGCM."
|
||||
},
|
||||
"Encrypt(plaintext, aad []byte)": {
|
||||
type: "([]byte, error)",
|
||||
required: true,
|
||||
description: "Draws a fresh 12-byte nonce from crypto/rand, seals, and returns nonce || ciphertext || tag. Accepts empty plaintext and nil aad."
|
||||
},
|
||||
"Decrypt(data, aad []byte)": {
|
||||
type: "([]byte, error)",
|
||||
required: true,
|
||||
description: "Expects nonce || ciphertext || tag. Errors if len(data) < 28, or if the tag or AAD does not verify."
|
||||
},
|
||||
"EncryptWithNonce(plaintext, aad, nonce []byte)": {
|
||||
type: "([]byte, error)",
|
||||
description: "Same output layout, but with a caller-supplied nonce. Errors unless len(nonce) == 12. See the danger note below."
|
||||
},
|
||||
"GetNonceSize()": {
|
||||
type: "int",
|
||||
description: "Always returns the NonceSize constant, 12."
|
||||
},
|
||||
"GetTagSize()": {
|
||||
type: "int",
|
||||
description: "Always returns the TagSize constant, 16."
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
`AESGCMCipher` has exactly one field, an unexported `gcm cipher.AEAD`. There is nothing to configure and nothing to inspect.
|
||||
|
||||
## Caveats
|
||||
|
||||
:::danger[EncryptWithNonce: never repeat a nonce under the same key]
|
||||
`EncryptWithNonce` hands you the nonce. Its own source comment says *"WARNING: Nonce reuse can compromise security. Use only for testing."* Take that literally.
|
||||
|
||||
Encrypting two different plaintexts with the same `(key, nonce)` pair in GCM:
|
||||
|
||||
- **Destroys confidentiality.** GCM is counter mode. The keystream is a function of the key and nonce alone, so two ciphertexts under a repeated nonce XOR to the XOR of their plaintexts.
|
||||
- **Destroys authenticity.** A single nonce repetition leaks enough information about the GHASH subkey to let an attacker forge tags for *other* messages under that key. This is not a graceful degradation; the whole key is burned.
|
||||
|
||||
The method exists so you can reproduce fixed test vectors and satisfy protocols that specify the nonce externally. If you call it in production, the nonce must come from a source that provably never repeats for a given key — a persisted, atomically incremented counter, not a timestamp and not a hash of the plaintext. If you cannot prove uniqueness, call `Encrypt` and let it draw from `crypto/rand`, or move to [`daed`](/symmetric/deterministic-aead), which is designed to be safe without a nonce.
|
||||
:::
|
||||
|
||||
:::warning[The AAD is your responsibility]
|
||||
`Decrypt` fails if the AAD differs by a single byte, and the AAD is not carried in the ciphertext. If you encrypt with a record ID as AAD and later change how that ID is serialized, every existing ciphertext becomes undecryptable. Pin the AAD encoding as strictly as you pin the wire format.
|
||||
:::
|
||||
|
||||
:::warning[No algorithm or key identifier on the wire]
|
||||
The output is `nonce || ciphertext || tag` with no header. There is no version byte, no key ID, and no way to tell an `aead` ciphertext from any other 12-byte-prefixed blob. If you expect to rotate keys or migrate ciphers, add your own framing now — retrofitting it means re-encrypting everything.
|
||||
:::
|
||||
|
||||
:::note[Error strings are wrapped, not typed]
|
||||
Every failure path returns a `fmt.Errorf` string; there are no sentinel error values to match with `errors.Is`. Authentication failure and malformed-length failure are distinguishable only by their message text. Treat any error from `Decrypt` as "this ciphertext is not authentic" and do not branch on the reason.
|
||||
:::
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Deterministic AEAD" href="/symmetric/deterministic-aead" icon="repeat">
|
||||
When you cannot carry a nonce, or need identical ciphertext for identical plaintext.
|
||||
</Card>
|
||||
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
|
||||
Where the 32-byte key comes from: Argon2id for passwords, HKDF for high-entropy secrets.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
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)
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Symmetric & Secrets
|
||||
description: Bulk encryption, password-based key derivation, and the secret-hygiene helpers — how to pick between them and how they compose.
|
||||
sidebar:
|
||||
order: 1
|
||||
icon: lock
|
||||
---
|
||||
|
||||
This is the "boring" half of the library, and the half you will actually touch on every request path. Nothing here is generic over the elliptic-curve abstraction: every package on these pages takes and returns `[]byte`. That makes the layer easy to reason about and easy to misuse, so each page is explicit about the exact key sizes, ciphertext layouts, and constants involved.
|
||||
|
||||
The packages split into three jobs:
|
||||
|
||||
- **Encrypt bytes.** [`aead`](/symmetric/aead) is randomized AES-256-GCM — the default choice. [`daed`](/symmetric/deterministic-aead) is AES-SIV, a deterministic AEAD for the narrow cases where you need the same plaintext to produce the same ciphertext.
|
||||
- **Turn a secret into a key.** [`argon2`](/symmetric/key-derivation) stretches a human password into key material and produces PHC-encoded password hashes. [`subtle`](/symmetric/key-derivation#subtle-hkdf-and-x25519) is the HKDF + X25519 layer for turning an already-high-entropy secret (a shared secret, a master key) into per-purpose subkeys.
|
||||
- **Handle the secret carefully.** [`secure`](/symmetric/secrets), [`salt`](/symmetric/secrets#salt), [`password`](/symmetric/secrets#password), and [`subtle/random`](/symmetric/secrets#subtle-random) are small hygiene helpers: zeroization, constant-time comparison, salt generation and storage, password policy checks, and raw randomness.
|
||||
|
||||
## Pick one
|
||||
|
||||
| Your goal | Package | Entry point |
|
||||
| --- | --- | --- |
|
||||
| Encrypt a payload, blob, or message | `aead` | `NewAESGCM(key)` → `Encrypt(pt, aad)` |
|
||||
| Encrypt a lookup key or identifier you must still be able to search by | `daed` | `NewAESSIV(key)` → `EncryptDeterministically` |
|
||||
| Wrap key material with no nonce to manage | `daed` | `NewAESSIV(key)` |
|
||||
| Derive an encryption key from a user's password | `argon2` | `New(DefaultConfig()).DeriveKey(pw, salt)` |
|
||||
| Store a verifiable password hash | `argon2` | `HashPassword` / `VerifyPassword` |
|
||||
| Split one master secret into several purpose-bound subkeys | `subtle` | `ComputeHKDF("SHA256", key, salt, info, 32)` |
|
||||
| Agree on a key with a remote peer | `subtle` | X25519 trio → `ComputeHKDF` |
|
||||
| Generate a salt | `salt` or `argon2` | `salt.GenerateDefault()` / `kdf.GenerateSalt()` |
|
||||
| Generate raw random bytes | `secure` | `SecureRandom(buf)` |
|
||||
| Compare two secrets without leaking timing | `argon2` | `CompareHashes(a, b)` |
|
||||
| Zero a key out of memory after use | `secure` | `Zeroize(key)` |
|
||||
| Enforce a password policy at signup | `password` | `NewValidator(nil).Validate(pw)` |
|
||||
|
||||
## How they compose
|
||||
|
||||
The realistic pipeline is: get entropy, stretch or expand it into a 32-byte key, encrypt with that key, then zero the key.
|
||||
|
||||
```go
|
||||
package vault
|
||||
|
||||
import (
|
||||
"github.com/sonr-io/crypto/aead"
|
||||
"github.com/sonr-io/crypto/argon2"
|
||||
"github.com/sonr-io/crypto/secure"
|
||||
)
|
||||
|
||||
func sealWithPassword(password, plaintext, aad []byte) (key, salt, ct []byte, err error) {
|
||||
kdf := argon2.New(argon2.DefaultConfig()) // Argon2id, 64 MiB, t=1, p=4
|
||||
|
||||
salt, err = kdf.GenerateSalt() // 32 bytes
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
key = kdf.DeriveKey(password, salt) // 32 bytes == aead.KeySize
|
||||
defer secure.Zeroize(key)
|
||||
|
||||
cipher, err := aead.NewAESGCM(key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
ct, err = cipher.Encrypt(plaintext, aad) // nonce || ciphertext || tag
|
||||
return key, salt, ct, err
|
||||
}
|
||||
```
|
||||
|
||||
Two things make that snippet work, and both are worth internalizing:
|
||||
|
||||
1. `argon2.DefaultConfig().KeyLength` is `32`, and `aead.KeySize` is `32`. The KDF output plugs straight into the AEAD constructor with no truncation or padding.
|
||||
2. `aead.Encrypt` generates its own nonce and prepends it, so the only thing you have to persist alongside the ciphertext is the salt.
|
||||
|
||||
:::tip[Choose the KDF by input entropy, not by habit]
|
||||
`argon2` is deliberately slow and memory-hard because its input is a low-entropy human password. If your input is *already* a uniformly random 32-byte secret — an X25519 shared secret, a master key from an HSM, a threshold-signing output — use `subtle.ComputeHKDF` instead. Running Argon2 on high-entropy input buys you nothing but 64 MiB of allocation per call.
|
||||
:::
|
||||
|
||||
## Sizes at a glance
|
||||
|
||||
Every one of these is a compile-time constant in the package named, not a default you can override. Getting a size wrong is a constructor error, never a silent truncation.
|
||||
|
||||
| Constant | Value | Where |
|
||||
| --- | --- | --- |
|
||||
| `aead.KeySize` | `32` | AES-256-GCM key — the only length accepted |
|
||||
| `aead.NonceSize` | `12` | GCM nonce, generated and prepended by `Encrypt` |
|
||||
| `aead.TagSize` | `16` | GCM authentication tag |
|
||||
| `daed.AESSIVKeySize` | `64` | AES-SIV double-length key: 32-byte CMAC key ‖ 32-byte CTR key |
|
||||
| `salt.DefaultSaltSize` | `32` | Recommended salt size |
|
||||
| `salt.MinSaltSize` / `salt.MaxSaltSize` | `16` / `1024` | Hard bounds enforced by `salt.Generate` |
|
||||
|
||||
An `aead` ciphertext is therefore always `len(plaintext) + 28` bytes; an AES-SIV ciphertext is always `len(plaintext) + 16`. Neither carries a version byte or key identifier, so any framing you need is yours to add.
|
||||
|
||||
## What stays your responsibility
|
||||
|
||||
These packages cover the primitive, not the protocol. Everything below is out of scope for this layer and has to live in your application:
|
||||
|
||||
- **Key lifetime and rotation.** Nothing tracks how many messages a key has protected, and nothing versions a key. `aead` will happily encrypt forever under one key.
|
||||
- **Salt and hash persistence.** `salt.SaltStore` is an in-memory map with no mutex; `argon2.HashPassword` is the only helper that packages a salt into something durable.
|
||||
- **Replay and freshness.** AEAD authenticity says "this ciphertext was produced by someone holding the key", never "recently" or "once". Bind timestamps or counters into the AAD.
|
||||
- **Rate limiting on password paths.** `argon2` makes each guess expensive; it does not make guessing impossible.
|
||||
- **Unicode normalization** of anything a human types, before both validation and derivation.
|
||||
|
||||
## What this layer does not do
|
||||
|
||||
:::warning[Read the caveats on each page]
|
||||
Several packages here are hand-rolled reimplementations of things the standard library already provides — `secure.SecureCompare`, `salt`'s internal `constantTimeCompare`, and `password.SecureCompare` are three separate copies of the same loop, none of which use `crypto/subtle`. `secure` does not lock pages into RAM. `password`'s entropy estimator is a length heuristic, not an entropy measurement. `argon2.EstimateTime` is a formula with no measurement behind it. Each page states exactly what the code does rather than what the name suggests.
|
||||
:::
|
||||
|
||||
There is no key *storage* here — no keystore, no envelope format, no versioned header. `aead.Encrypt` hands you `nonce || ciphertext || tag` and nothing else; if you need an algorithm identifier or a key ID on the wire, you frame it yourself. Likewise there is no ChaCha20-Poly1305, no AES-128, and no streaming/chunked API: `aead` is AES-256-GCM one-shot only.
|
||||
|
||||
## Next
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
|
||||
AES-256-GCM. The default for encrypting anything. Exact key size, nonce handling, and ciphertext layout.
|
||||
</Card>
|
||||
<Card title="Deterministic AEAD" href="/symmetric/deterministic-aead" icon="repeat">
|
||||
AES-SIV with a 64-byte key. Nonce-free and misuse-resistant, at the cost of leaking plaintext equality.
|
||||
</Card>
|
||||
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
|
||||
Argon2id presets with exact parameters, PHC hash encoding, HKDF, and the X25519 ECDH trio.
|
||||
</Card>
|
||||
<Card title="Secret hygiene" href="/symmetric/secrets" icon="eye-off">
|
||||
Zeroization, salts and the salt store, password policy, randomness — and what each one really guarantees.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Everything outside this layer — signatures, threshold protocols, zero-knowledge proofs — is generic over the curve abstraction described in [Foundations: curves](/foundations/curves).
|
||||
@@ -0,0 +1,341 @@
|
||||
---
|
||||
title: Key Derivation
|
||||
description: Argon2id password stretching with exact preset parameters and PHC hash encoding, plus the subtle package's HKDF, hash/curve name mapping, and X25519 ECDH.
|
||||
sidebar:
|
||||
order: 4
|
||||
icon: key-round
|
||||
---
|
||||
|
||||
Two packages, two different jobs, and picking the wrong one is the most common mistake in this layer.
|
||||
|
||||
- **`argon2`** takes a *low-entropy* secret — a human password — and spends deliberate time and memory turning it into key material. Use it when a human typed the input.
|
||||
- **`subtle`** takes a *high-entropy* secret — an X25519 shared secret, a master key, a random 32-byte seed — and expands it cheaply into as many purpose-bound subkeys as you need via HKDF. Use it when a CSPRNG or a Diffie-Hellman produced the input.
|
||||
|
||||
Running Argon2 on a random 32-byte key wastes 64 MiB and several hundred milliseconds for no security gain. Running HKDF on a user password produces a key that is exactly as guessable as the password.
|
||||
|
||||
## argon2 password stretching
|
||||
|
||||
`github.com/sonr-io/crypto/argon2` wraps `golang.org/x/crypto/argon2`. It uses **Argon2id** exclusively — `DeriveKey` calls `argon2.IDKey`, and the encoded hash format hardcodes the `argon2id` label. There is no way to select Argon2i or Argon2d through this API, which is the right default: id is the hybrid variant recommended for password hashing because it resists both side-channel and time-memory-tradeoff attacks.
|
||||
|
||||
### Preset parameters
|
||||
|
||||
These are the exact literals from `argon2/kdf.go`. `Memory` is in **kibibytes**, matching the underlying `argon2.IDKey` signature.
|
||||
|
||||
| Field | `LightConfig()` | `DefaultConfig()` | `HighSecurityConfig()` |
|
||||
| --- | --- | --- | --- |
|
||||
| `Time` (iterations) | `1` | `1` | `3` |
|
||||
| `Memory` (KiB) | `16384` (16 MiB) | `65536` (64 MiB) | `131072` (128 MiB) |
|
||||
| `Parallelism` (threads) | `2` | `4` | `4` |
|
||||
| `SaltLength` (bytes) | `16` | `32` | `32` |
|
||||
| `KeyLength` (bytes) | `32` | `32` | `32` |
|
||||
|
||||
All three produce a 32-byte key, which is exactly `aead.KeySize`, so any preset's output plugs directly into [`aead.NewAESGCM`](/symmetric/aead).
|
||||
|
||||
`LightConfig` is described in source as "lighter parameters for testing". Use it in tests and CI, not for real credentials.
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
Time: {
|
||||
type: "uint32",
|
||||
required: true,
|
||||
description: "Number of Argon2 passes over memory. Must be >= 1.",
|
||||
default: "1"
|
||||
},
|
||||
Memory: {
|
||||
type: "uint32",
|
||||
required: true,
|
||||
description: "Memory cost in kibibytes. ValidateConfig requires >= 8192 (8 MiB).",
|
||||
default: "65536"
|
||||
},
|
||||
Parallelism: {
|
||||
type: "uint8",
|
||||
required: true,
|
||||
description: "Number of lanes/threads. Must be >= 1.",
|
||||
default: "4"
|
||||
},
|
||||
SaltLength: {
|
||||
type: "uint32",
|
||||
required: true,
|
||||
description: "Size of salts produced by GenerateSalt. Must be >= 8. Not enforced on salts you pass to DeriveKey yourself.",
|
||||
default: "32"
|
||||
},
|
||||
KeyLength: {
|
||||
type: "uint32",
|
||||
required: true,
|
||||
description: "Output key length in bytes. Must be >= 16.",
|
||||
default: "32"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
`ValidateConfig` enforces the floors listed above — `Time >= 1`, `Memory >= 8*1024`, `Parallelism >= 1`, `SaltLength >= 8`, `KeyLength >= 16` — and returns a plain error naming the first violated bound.
|
||||
|
||||
### Deriving a key
|
||||
|
||||
Grounded in `argon2/kdf_test.go` (`TestKDF_DeriveKey`, `TestKDF_GenerateSalt`):
|
||||
|
||||
```go derive.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/argon2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := argon2.DefaultConfig()
|
||||
if err := argon2.ValidateConfig(cfg); err != nil { // New() does not validate for you
|
||||
panic(err)
|
||||
}
|
||||
|
||||
kdf := argon2.New(cfg) // New(nil) falls back to DefaultConfig()
|
||||
|
||||
salt, err := kdf.GenerateSalt() // cfg.SaltLength == 32 bytes
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
key := kdf.DeriveKey([]byte("correct horse battery staple"), salt)
|
||||
fmt.Println(len(key)) // 32 == cfg.KeyLength
|
||||
}
|
||||
```
|
||||
|
||||
`DeriveKey` returns no error — every failure mode of Argon2id is a programming error rather than a runtime one — and it is deterministic in `(password, salt, config)`. It is safe to call concurrently from many goroutines on one `*KDF`; the package's `TestConcurrentDerivation` does exactly that. Remember that each concurrent call allocates `Memory` kibibytes, so N parallel derivations with `DefaultConfig` reserve N × 64 MiB.
|
||||
|
||||
### Password hashes and the PHC string
|
||||
|
||||
`HashPassword` is the "store this in your users table" path. It generates a fresh salt, derives the key, and encodes everything needed to verify later into one self-describing string:
|
||||
|
||||
```text
|
||||
$argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>
|
||||
│ │ │ │ └─ derived key, base64.RawStdEncoding
|
||||
│ │ │ └──────── salt, base64.RawStdEncoding
|
||||
│ │ └───────────────────────── Memory,Time,Parallelism from the config
|
||||
│ └────────────────────────────── argon2.Version, always 19
|
||||
└─────────────────────────────────────── variant label, always "argon2id"
|
||||
```
|
||||
|
||||
The two base64 segments use `base64.RawStdEncoding`: **standard** alphabet (`+` and `/`, not URL-safe) with **no** `=` padding. Splitting the string on `$` yields exactly six parts, the first being empty.
|
||||
|
||||
`VerifyPassword` is the inverse and is a package-level function, not a method — it does not need your `*KDF` because it reads the parameters back out of the string:
|
||||
|
||||
```go verify.go
|
||||
kdf := argon2.New(argon2.DefaultConfig())
|
||||
|
||||
encoded, err := kdf.HashPassword([]byte("MySecureP@ssw0rd"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// encoded == "$argon2id$v=19$m=65536,t=1,p=4$...$..."
|
||||
|
||||
ok, err := argon2.VerifyPassword([]byte("MySecureP@ssw0rd"), encoded)
|
||||
if err != nil {
|
||||
panic(err) // malformed string, wrong variant, or unsupported version
|
||||
}
|
||||
fmt.Println(ok) // true
|
||||
|
||||
ok, _ = argon2.VerifyPassword([]byte("wrong"), encoded)
|
||||
fmt.Println(ok) // false, err == nil
|
||||
```
|
||||
|
||||
Note the two-channel result: `err` means *the hash string is unusable*, `ok == false` means *the password is wrong*. Never collapse them — treating a parse error as a failed login masks corruption in your credential store.
|
||||
|
||||
The final comparison uses `crypto/subtle.ConstantTimeCompare`. `CompareHashes(a, b)` exposes the same primitive for comparing any two byte slices, and it is the constant-time comparison you should prefer across this whole library — see the [note on duplicated helpers](/symmetric/secrets#duplicated-helpers).
|
||||
|
||||
### argon2 caveats
|
||||
|
||||
:::danger[VerifyPassword trusts the parameters in the hash string]
|
||||
`decodeHash` parses `m`, `t`, and `p` out of the encoded hash and `VerifyPassword` derives with those values, not with your configured ones. That is what makes stored hashes upgradeable — but it also means the cost of a verification is chosen by whoever supplied the string.
|
||||
|
||||
If an attacker can get an arbitrary encoded hash into the verify path (a "check this hash" endpoint, an imported credential file, a tenant-supplied record), `m=4194304` makes your process try to allocate 4 GiB per call. Only ever call `VerifyPassword` with strings your own `HashPassword` produced and your own storage returned, and if a hash can come from outside, parse and bound `m`/`t`/`p` yourself before verifying.
|
||||
:::
|
||||
|
||||
:::warning[New() and DeriveKey() never validate the config]
|
||||
`ValidateConfig` exists but nothing in the package calls it. `New(&Config{Time: 0, Memory: 1, Parallelism: 0, KeyLength: 1})` returns a working `*KDF`, and `DeriveKey` hands those values straight to `argon2.IDKey`, which **panics** rather than erroring on out-of-range parameters — `golang.org/x/crypto/argon2` panics with `argon2: number of rounds too small` for `Time < 1` and `argon2: parallelism degree too low` for `Parallelism < 1`. A bad config is therefore a crash on the derivation path, not a returned error. Call `ValidateConfig` yourself on any config you did not get from one of the three preset constructors.
|
||||
|
||||
`DeriveKey` also ignores `SaltLength` entirely: it uses whatever slice you pass. A one-byte salt is accepted silently. Get salts from `GenerateSalt` or from the [`salt`](/symmetric/secrets#salt) package.
|
||||
:::
|
||||
|
||||
:::warning[EstimateTime is a formula, not a measurement]
|
||||
`EstimateTime(config, iterations)` computes `Time * Memory / 65536 * iterations` and formats the result as `"1.00 s"`. There is no benchmark, no calibration, and no hardware input behind it — with `DefaultConfig()` and one iteration it returns exactly `"1.00 s"` by construction, whatever machine you are on. Its own comment calls it "a rough estimate". Do not surface its output to users or use it to pick parameters; measure on your target hardware instead.
|
||||
:::
|
||||
|
||||
:::note[No pepper, no rehash-on-login helper]
|
||||
There is no application-wide secret ("pepper") input, and no helper that detects a stored hash using outdated parameters and transparently upgrades it. If you tighten your config, you must compare the parsed `m`/`t`/`p` against your current settings after a successful verification and re-hash yourself.
|
||||
:::
|
||||
|
||||
## subtle HKDF and X25519
|
||||
|
||||
`github.com/sonr-io/crypto/subtle` is a Tink-derived helper package: HKDF, a hash-function registry keyed by string, an elliptic-curve registry keyed by string, and the three X25519 functions. It is where you go for cheap expansion of an already-random secret.
|
||||
|
||||
:::note[This package has no tests and no in-repo callers]
|
||||
Unlike every other package on this page, `subtle` ships with no `_test.go` file, and nothing else in this module imports it (only its `subtle/random` subpackage is used elsewhere). The code is short and closely follows upstream Tink, but the examples below are derived from the signatures and behaviour in `hkdf.go`, `subtle.go`, and `x25519.go` rather than from an executed test. Validate against your own vectors before depending on it.
|
||||
:::
|
||||
|
||||
### Accepted name strings
|
||||
|
||||
`GetHashFunc`, `GetHashDigestSize`, and `ComputeHKDF` all key off a hash **name string**, and they return `nil` / an error for anything unrecognised. The accepted spellings are exact:
|
||||
|
||||
| Hash name | Digest size (`GetHashDigestSize`) | Backing function |
|
||||
| --- | --- | --- |
|
||||
| `"SHA1"` | `20` | `sha1.New` |
|
||||
| `"SHA224"` | `28` | `sha256.New224` |
|
||||
| `"SHA256"` | `32` | `sha256.New` |
|
||||
| `"SHA384"` | `48` | `sha512.New384` |
|
||||
| `"SHA512"` | `64` | `sha512.New` |
|
||||
|
||||
`ConvertHashName` normalises the hyphenated spellings into the above — `"SHA-1"`→`"SHA1"`, `"SHA-224"`→`"SHA224"`, `"SHA-256"`→`"SHA256"`, `"SHA-384"`→`"SHA384"`, `"SHA-512"`→`"SHA512"` — and returns the **empty string** for anything else.
|
||||
|
||||
`ConvertCurveName` and `GetCurve` work the same way for NIST curves:
|
||||
|
||||
| Input to `ConvertCurveName` | Canonical name | `GetCurve` returns |
|
||||
| --- | --- | --- |
|
||||
| `"secp256r1"`, `"P-256"` | `"NIST_P256"` | `elliptic.P256()` |
|
||||
| `"secp384r1"`, `"P-384"` | `"NIST_P384"` | `elliptic.P384()` |
|
||||
| `"secp521r1"`, `"P-521"` | `"NIST_P521"` | `elliptic.P521()` |
|
||||
|
||||
:::warning[Unknown names fail silently as zero values]
|
||||
`ConvertHashName("sha256")` — lowercase — returns `""`, not an error. `GetHashFunc("")` returns a `nil` function, and `GetCurve("P-256")` returns `nil` because it wants the *converted* name `"NIST_P256"`. The source carries an upstream `TODO(ckl)` acknowledging that these should return explicit errors. Always check for `""` / `nil` after a lookup; `ComputeHash(nil, data)` at least fails loudly with `nil hash function`.
|
||||
:::
|
||||
|
||||
### ComputeHKDF
|
||||
|
||||
```go
|
||||
func ComputeHKDF(hashAlg string, key, salt, info []byte, tagSize uint32) ([]byte, error)
|
||||
```
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
hashAlg: {
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "One of SHA1, SHA224, SHA256, SHA384, SHA512. Anything else errors with 'hkdf: invalid hash algorithm'."
|
||||
},
|
||||
key: {
|
||||
type: "[]byte",
|
||||
required: true,
|
||||
description: "Input keying material (IKM). Its length is NOT validated — an empty key is accepted."
|
||||
},
|
||||
salt: {
|
||||
type: "[]byte",
|
||||
description: "Optional. If empty or nil it is replaced by a zero-filled slice of the hash's digest size, per RFC 5869."
|
||||
},
|
||||
info: {
|
||||
type: "[]byte",
|
||||
description: "Context/application binding. Use a distinct, versioned label per derived key."
|
||||
},
|
||||
tagSize: {
|
||||
type: "uint32",
|
||||
required: true,
|
||||
description: "Output length in bytes. Must be >= 10 ('tag size too small') and <= 255 * digestSize ('tag size too big')."
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
The 10-byte floor is a named constant, `minTagSizeInBytes`, documented in source as providing at least 80-bit security strength. The `255 * digestSize` ceiling is HKDF's structural maximum.
|
||||
|
||||
### X25519 ECDH → HKDF → AEAD
|
||||
|
||||
This is the pipeline `subtle` exists to serve: agree on a shared secret with a peer, expand it into a purpose-bound AEAD key, encrypt.
|
||||
|
||||
```go pipeline.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/aead"
|
||||
"github.com/sonr-io/crypto/secure"
|
||||
"github.com/sonr-io/crypto/subtle"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Each side generates a 32-byte X25519 private key and publishes the public value.
|
||||
alicePriv, err := subtle.GeneratePrivateKeyX25519()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
alicePub, err := subtle.PublicFromPrivateX25519(alicePriv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
bobPriv, err := subtle.GeneratePrivateKeyX25519()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bobPub, err := subtle.PublicFromPrivateX25519(bobPriv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// 2. Both sides compute the same 32-byte shared secret. Always check the error.
|
||||
aliceSecret, err := subtle.ComputeSharedSecretX25519(alicePriv, bobPub)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bobSecret, err := subtle.ComputeSharedSecretX25519(bobPriv, alicePub)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer secure.ZeroizeMultiple(aliceSecret, bobSecret)
|
||||
|
||||
// 3. Never use the raw DH output as a key. Expand it, binding both public
|
||||
// values and a versioned label into `info`.
|
||||
info := append(append([]byte("sonr/x25519-aead/v1|"), alicePub...), bobPub...)
|
||||
key, err := subtle.ComputeHKDF("SHA256", aliceSecret, nil, info, aead.KeySize)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer secure.Zeroize(key)
|
||||
|
||||
// 4. Encrypt.
|
||||
c, err := aead.NewAESGCM(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ct, err := c.Encrypt([]byte("hello"), nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(len(ct)) // 12 + 5 + 16
|
||||
}
|
||||
```
|
||||
|
||||
Deriving several keys from one secret is the same call with a different `info`, which is the entire point of the label:
|
||||
|
||||
```go
|
||||
sendKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("c2s v1"), 32)
|
||||
recvKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("s2c v1"), 32)
|
||||
sivKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("wrap v1"), 64) // daed.AESSIVKeySize
|
||||
```
|
||||
|
||||
### subtle caveats
|
||||
|
||||
:::danger[Raw Diffie-Hellman output is not a key]
|
||||
`ComputeSharedSecretX25519` returns the X-coordinate of the scalar multiplication. It is 32 bytes but it is not uniformly distributed, and using it directly as an AES key is a real weakness. Always pass it through `ComputeHKDF` (or another KDF) with an `info` label that binds the protocol, the version, and both parties' public values. The `PublicFromPrivateX25519` function is itself just `ComputeSharedSecretX25519(privKey, curve25519.Basepoint)`, so the same 32-byte shape means "public key" in one place and "shared secret" in another — do not let those slices get mixed up in your code.
|
||||
:::
|
||||
|
||||
:::warning[Always check the X25519 error before touching the result]
|
||||
All three X25519 functions return `([]byte, error)`, and `GeneratePrivateKeyX25519` in particular returns its buffer *and* the error together — the slice is non-nil even on failure. `ComputeSharedSecretX25519` delegates to `curve25519.X25519`, which reports degenerate inputs as an error rather than handing back a weak secret. Silently ignoring these errors is how you end up encrypting under an all-zero or partially-initialised key.
|
||||
:::
|
||||
|
||||
:::warning[ComputeHKDF does not validate the input key length]
|
||||
`validateHKDFParams` takes the key size as an ignored `_ uint32` parameter and only checks the hash name and the tag size. `ComputeHKDF("SHA256", nil, nil, info, 32)` succeeds and returns a deterministic 32 bytes derived from nothing. Verify your IKM is non-empty and genuinely high-entropy before calling.
|
||||
:::
|
||||
|
||||
:::note[SHA1 is still reachable]
|
||||
`"SHA1"` remains in the hash registry, so `ComputeHKDF("SHA1", ...)` works. HMAC-SHA1 is not broken as a PRF, but there is no reason to pick it for new work — use `"SHA256"` unless an existing protocol pins SHA-1.
|
||||
:::
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Secret hygiene" href="/symmetric/secrets" icon="eye-off">
|
||||
Salts, zeroization, password policy, and which of the three duplicated SecureCompare helpers to prefer.
|
||||
</Card>
|
||||
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
|
||||
Where the 32-byte derived key gets used.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({
|
||||
title: "Symmetric & Secrets",
|
||||
icon: "lock",
|
||||
order: 3,
|
||||
pages: ["index", "aead", "deterministic-aead", "key-derivation", "secrets"],
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
title: Secret Hygiene
|
||||
description: The secure, salt, password, and subtle/random helpers — zeroization, salt management, password policy, and randomness, with an honest account of what each actually guarantees.
|
||||
sidebar:
|
||||
order: 5
|
||||
icon: eye-off
|
||||
---
|
||||
|
||||
Four small packages that surround the cryptography rather than performing it: wiping key material after use, generating and tracking salts, enforcing a password policy at signup, and getting random bytes. None of them is load-bearing for confidentiality — a correct `aead` call with a correctly derived key is secure whether or not you zeroize afterwards — but they are the difference between a key living for microseconds and living until the process exits into a core dump.
|
||||
|
||||
They are also the least polished corner of this library. Several helpers are duplicated across packages, one guarantee is weaker than its name suggests, and one type is not safe for concurrent use. Everything below states what the code does.
|
||||
|
||||
## secure — zeroization and wrapped secrets
|
||||
|
||||
`github.com/sonr-io/crypto/secure` provides free functions for wiping and comparing byte slices, plus three container types that wipe themselves.
|
||||
|
||||
### The guarantee, precisely
|
||||
|
||||
:::warning[This package overwrites memory. It does not lock it.]
|
||||
Read `secure/memory.go` and you will find `crypto/rand`, `fmt`, `runtime`, and `sync` — and nothing else. There is **no** `mlock`, no `munlock`, no `madvise`, no `syscall` import, and no build-tagged platform file. Concretely:
|
||||
|
||||
- Secrets held by this package **can be paged to swap** or captured in a core dump or hibernation image. If that matters, disable swap for the process, or lock pages yourself outside this library.
|
||||
- `Zeroize` is a plain `for i := range data { data[i] = 0 }` followed by `runtime.KeepAlive(data)`. The comment claims the loop "prevent[s] compiler optimizations"; in practice a loop writing to a heap slice that is later kept alive is not something the current Go compiler elides, but this is a convention, not a language guarantee. Go has no `explicit_bzero`.
|
||||
- **The Go runtime may already have copied your secret.** A growing slice, an `append`, a map rehash, or a moving GC leaves stale copies that `Zeroize` cannot reach, because it only sees the slice header you hand it. Zeroize the *original* buffer as early as possible and avoid copying secrets into intermediate values.
|
||||
|
||||
Treat zeroization as defence in depth that shortens a secret's lifetime, not as a boundary that guarantees erasure.
|
||||
:::
|
||||
|
||||
:::danger[ZeroizeString is a no-op on the actual bytes]
|
||||
`ZeroizeString(s *string)` does exactly one thing: `*s = ""`. Its own comment says "(limited effectiveness)". Go strings are immutable and their backing bytes are not writable through the language, so the original characters remain in the heap until the GC collects them — and if the string was interned, is a compile-time constant, or is shared with any other variable, they remain reachable and unchanged. `SecureString.Clear()` calls this function, so `SecureString` inherits the same limitation.
|
||||
|
||||
The fix is not a better `ZeroizeString`; it is to never put a secret in a `string`. Read passwords and keys into `[]byte`, pass `[]byte` all the way down (`argon2.DeriveKey`, `password.Validate`, and `aead.Encrypt` all take `[]byte`), and `Zeroize` that.
|
||||
:::
|
||||
|
||||
### Free functions
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
"Zeroize(data []byte)": {
|
||||
type: "void",
|
||||
description: "Overwrites every byte with 0, then runtime.KeepAlive. Returns immediately for a nil or empty slice."
|
||||
},
|
||||
"ZeroizeMultiple(slices ...[]byte)": {
|
||||
type: "void",
|
||||
description: "Calls Zeroize on each argument. Convenient for a deferred wipe of several buffers."
|
||||
},
|
||||
"ZeroizeString(s *string)": {
|
||||
type: "void",
|
||||
description: "Sets *s = \"\". Does not and cannot overwrite the string's bytes. Nil-safe."
|
||||
},
|
||||
"SecureCompare(a, b []byte) bool": {
|
||||
type: "bool",
|
||||
description: "Hand-rolled XOR-accumulate comparison. Returns false immediately when lengths differ, so length is not hidden."
|
||||
},
|
||||
"SecureRandom(data []byte) error": {
|
||||
type: "error",
|
||||
description: "Fills data from crypto/rand.Read. Returns nil for an empty slice. Wraps any read failure as an error rather than panicking."
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
`SecureRandom` is the randomness call to prefer in this library: it reports failure instead of panicking, unlike [`subtle/random`](#subtle-random).
|
||||
|
||||
### SecureBytes
|
||||
|
||||
A mutex-guarded byte buffer with a `runtime.SetFinalizer` that wipes it if you forget to. Grounded in `secure/memory_test.go` (`TestSecureBytes`):
|
||||
|
||||
```go secure_bytes.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/secure"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Allocate a zeroed 32-byte secret holder.
|
||||
sb := secure.NewSecureBytes(32)
|
||||
defer sb.Clear() // idempotent; also removes the finalizer
|
||||
|
||||
// Bytes() hands back a copy, so writes must go through CopyTo.
|
||||
scratch := make([]byte, sb.Size())
|
||||
if err := secure.SecureRandom(scratch); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := sb.CopyTo(scratch); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
secure.Zeroize(scratch) // wipe the intermediate immediately
|
||||
|
||||
fmt.Println(sb.Size(), sb.IsEmpty()) // 32 false
|
||||
|
||||
// Wrapping existing material copies it — the source stays independent.
|
||||
raw := []byte{1, 2, 3, 4, 5}
|
||||
wrapped := secure.FromBytes(raw)
|
||||
secure.Zeroize(raw) // wiping the original does not affect `wrapped`
|
||||
|
||||
out := wrapped.Bytes() // a fresh copy: your responsibility now
|
||||
fmt.Println(out) // [1 2 3 4 5]
|
||||
secure.Zeroize(out)
|
||||
wrapped.Clear()
|
||||
}
|
||||
```
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
"NewSecureBytes(size int)": {
|
||||
type: "*SecureBytes",
|
||||
description: "Allocates a zeroed buffer of `size` bytes and registers a finalizer. size <= 0 yields a nil-data instance with NO finalizer."
|
||||
},
|
||||
"FromBytes(data []byte)": {
|
||||
type: "*SecureBytes",
|
||||
description: "Copies data into a new instance. Empty input yields a nil-data instance with no finalizer."
|
||||
},
|
||||
"Bytes()": {
|
||||
type: "[]byte",
|
||||
description: "Returns a fresh COPY of the contents — a new secret you are now responsible for zeroizing. Returns nil once cleared."
|
||||
},
|
||||
"CopyTo(data []byte)": {
|
||||
type: "error",
|
||||
description: "Zeroizes the buffer then copies data in. Errors if finalized, if the buffer is nil, or if len(data) exceeds the buffer."
|
||||
},
|
||||
Size: { type: "int", description: "Length of the held data; 0 after Clear." },
|
||||
IsEmpty: { type: "bool", description: "True if the data is nil or zero-length." },
|
||||
Clear: { type: "void", description: "Zeroizes, drops the data, marks finalized, and unregisters the finalizer. Safe to call twice." }
|
||||
}}
|
||||
/>
|
||||
|
||||
:::warning[Bytes() manufactures new copies of your secret]
|
||||
Every `Bytes()` call allocates and returns a fresh slice — that is what makes the type safe against external mutation, and it is also what makes it leaky. Each returned slice is an independent copy that `Clear()` will never touch. Call `Bytes()` once, use it, and `Zeroize` the result yourself.
|
||||
|
||||
Relying on the finalizer is worse still: `runtime.SetFinalizer` runs at the GC's discretion and is not guaranteed to run at all before the process exits. Always `defer sb.Clear()`.
|
||||
:::
|
||||
|
||||
### SecureString
|
||||
|
||||
`NewSecureString(s string)` wraps a string; `String()` returns the value (or `""` once cleared), `IsEmpty()` reports finalized-or-empty, and `Clear()` calls `ZeroizeString` and marks it finalized. Given the `ZeroizeString` limitation above, this type buys you a "cleared" flag and a mutex, not erasure. Prefer `SecureBytes`.
|
||||
|
||||
### SecureBuffer
|
||||
|
||||
A fixed-capacity append-only buffer for assembling sensitive data.
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
"NewSecureBuffer(capacity int)": {
|
||||
type: "*SecureBuffer",
|
||||
description: "Allocates make([]byte, 0, capacity). A capacity <= 0 is silently replaced with 1024."
|
||||
},
|
||||
"Write(data []byte)": {
|
||||
type: "error",
|
||||
description: "Appends. Returns a 'buffer overflow' error instead of growing when len+len(data) would exceed capacity."
|
||||
},
|
||||
"Read()": { type: "[]byte", description: "Returns a copy of the current contents." },
|
||||
"Reset()": { type: "void", description: "Zeroizes the entire backing array (up to cap) and truncates length to 0, retaining capacity. No-op when length is already 0." },
|
||||
"Clear()": { type: "void", description: "Zeroizes the entire backing array, sets it to nil, and unregisters the finalizer." },
|
||||
Size: { type: "int", description: "Current length." },
|
||||
Capacity: { type: "int", description: "Backing-array capacity; 0 after Clear." }
|
||||
}}
|
||||
/>
|
||||
|
||||
:::note[SecureBuffer never grows, and Reset skips an empty buffer]
|
||||
`Write` fails rather than reallocating — deliberate, since a growing slice would leave an un-wipeable copy behind, but it means you must size the buffer up front. Also note `Reset()` is guarded by `if len(sb.buffer) > 0`, so it does nothing when the length is already zero; after a `Reset` the capacity region stays wiped, but do not depend on `Reset` as a general "scrub this" call. After `Clear()`, capacity is 0 and every subsequent `Write` fails with an overflow error.
|
||||
:::
|
||||
|
||||
## salt
|
||||
|
||||
`github.com/sonr-io/crypto/salt` wraps salt bytes in a type that redacts itself in logs, compares in constant time, and can wipe itself — plus an in-memory keyed store.
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `salt.DefaultSaltSize` | `32` | Recommended size, 256 bits — matches `argon2.DefaultConfig().SaltLength` |
|
||||
| `salt.MinSaltSize` | `16` | Hard floor, 128 bits. Anything smaller is rejected |
|
||||
| `salt.MaxSaltSize` | `1024` | Hard ceiling, to prevent resource exhaustion |
|
||||
|
||||
`Generate(size)` errors outside `[16, 1024]`; `GenerateDefault()` is `Generate(32)`; `FromBytes(data)` applies the same bounds and **copies** the input so later mutation of your slice cannot change the salt.
|
||||
|
||||
```go salts.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/argon2"
|
||||
"github.com/sonr-io/crypto/salt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
s, err := salt.GenerateDefault() // 32 bytes
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer s.Clear()
|
||||
|
||||
fmt.Println(s.Size()) // 32
|
||||
fmt.Println(s.String()) // Salt{size=32} — value is never printed
|
||||
fmt.Println(s.IsEmpty()) // false
|
||||
|
||||
key := argon2.New(argon2.DefaultConfig()).DeriveKey([]byte("pw"), s.Bytes())
|
||||
fmt.Println(len(key)) // 32
|
||||
|
||||
// Round-tripping a persisted salt.
|
||||
restored, err := salt.FromBytes(s.Bytes())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(s.Equal(restored)) // true, compared in constant time
|
||||
}
|
||||
```
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
"Bytes()": { type: "[]byte", description: "Returns a copy. nil if the Salt is nil or cleared." },
|
||||
"Size()": { type: "int", description: "Length in bytes; 0 when nil or cleared." },
|
||||
"String()": { type: "string", description: "Redacted form: \"Salt{size=32}\" or \"Salt{<nil>}\". Never exposes the value." },
|
||||
"Equal(other *Salt)": { type: "bool", description: "Constant-time comparison over equal-length values; returns false on a length mismatch. Nil-safe (nil equals nil)." },
|
||||
"Clear()": { type: "void", description: "Zeroizes the value and sets it to nil. Nil-safe." },
|
||||
"IsEmpty()": { type: "bool", description: "True if the Salt is nil, has nil value, or is zero-length." }
|
||||
}}
|
||||
/>
|
||||
|
||||
Every `*Salt` method is nil-receiver safe, which is unusual and worth knowing: a `nil` salt reports `Size() == 0` and `IsEmpty() == true` rather than panicking.
|
||||
|
||||
### SaltStore
|
||||
|
||||
`NewSaltStore()` returns a keyed collection with `Store(id, salt)`, `Retrieve(id)`, `GenerateAndStore(id, size)`, `Remove(id)`, `List()`, `Size()`, and `Clear()`. `Store` and `Retrieve` both copy, so the store never shares a backing array with your code, and `Remove`/`Clear` zeroize before deleting. An empty `id` is an error, as is storing a nil-or-empty salt or retrieving/removing an unknown `id`.
|
||||
|
||||
:::danger[SaltStore is in-memory only and NOT concurrency-safe]
|
||||
Two independent facts, both from `salt.go`:
|
||||
|
||||
1. The struct is exactly `struct { salts map[string]*Salt }`. There is **no mutex** — no `sync.Mutex`, no `sync.RWMutex`, no `sync.Map`. Concurrent `Store` and `Retrieve` from different goroutines is a data race on a Go map, and concurrent writes will crash the process with `fatal error: concurrent map writes`. Wrap it in your own lock or confine it to one goroutine.
|
||||
2. There is no persistence, no encryption at rest, and no export/import. Everything lives in the process heap and is gone on restart. Salts do not need to be secret, but they do need to *survive* — a lost salt means an unverifiable password hash and an underivable key. Persist salts alongside the records they belong to (or use `argon2.HashPassword`, which embeds the salt in the encoded string) and treat `SaltStore` as a request-scoped cache at most.
|
||||
:::
|
||||
|
||||
## password
|
||||
|
||||
`github.com/sonr-io/crypto/password` is a policy checker, not a hasher — it never touches Argon2. Feed a candidate password through `Validate` at signup or change-password time, then hand it to [`argon2`](/symmetric/key-derivation#argon2-password-stretching).
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
MinLength: {
|
||||
type: "int",
|
||||
required: true,
|
||||
description: "Minimum length, compared against len(password) in BYTES.",
|
||||
default: "12"
|
||||
},
|
||||
MaxLength: {
|
||||
type: "int",
|
||||
required: true,
|
||||
description: "Maximum length in bytes.",
|
||||
default: "128"
|
||||
},
|
||||
RequireUppercase: {
|
||||
type: "bool",
|
||||
description: "Require at least one unicode.IsUpper rune.",
|
||||
default: "true"
|
||||
},
|
||||
RequireLowercase: {
|
||||
type: "bool",
|
||||
description: "Require at least one unicode.IsLower rune.",
|
||||
default: "true"
|
||||
},
|
||||
RequireDigits: {
|
||||
type: "bool",
|
||||
description: "Require at least one unicode.IsDigit rune.",
|
||||
default: "true"
|
||||
},
|
||||
RequireSpecial: {
|
||||
type: "bool",
|
||||
description: "Require at least one unicode.IsPunct or unicode.IsSymbol rune. Whitespace does NOT count as special.",
|
||||
default: "true"
|
||||
},
|
||||
MinEntropy: {
|
||||
type: "float64",
|
||||
required: true,
|
||||
description: "Minimum estimated entropy in bits, from the package's own heuristic (see caveat).",
|
||||
default: "50.0"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
Those are the literal values in `DefaultPasswordConfig()`. `NewValidator(nil)` uses them.
|
||||
|
||||
```go policy.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sonr-io/crypto/password"
|
||||
)
|
||||
|
||||
func main() {
|
||||
v := password.NewValidator(nil) // DefaultPasswordConfig()
|
||||
|
||||
// Rejected: 7 bytes < MinLength 12.
|
||||
fmt.Println(v.Validate([]byte("Short1!")))
|
||||
// password must be at least 12 characters
|
||||
|
||||
// Rejected: no uppercase.
|
||||
fmt.Println(v.Validate([]byte("longenoughpassword123!")))
|
||||
// password must contain at least one uppercase letter
|
||||
|
||||
// Accepted.
|
||||
fmt.Println(v.Validate([]byte("ValidPassword123!"))) // <nil>
|
||||
|
||||
// Loosen the policy explicitly rather than editing the default.
|
||||
relaxed := password.NewValidator(&password.PasswordConfig{
|
||||
MinLength: 8,
|
||||
MaxLength: 64,
|
||||
RequireUppercase: false,
|
||||
RequireLowercase: true,
|
||||
RequireDigits: true,
|
||||
RequireSpecial: false,
|
||||
MinEntropy: 30.0,
|
||||
})
|
||||
fmt.Println(relaxed.Validate([]byte("simple123"))) // <nil>
|
||||
}
|
||||
```
|
||||
|
||||
`Validate` returns the **first** violated rule as a `fmt.Errorf` string, checked in order: min length, max length, uppercase, lowercase, digit, special, entropy. There is no aggregated result, so a UI that wants to show every failure must call it repeatedly with narrowed configs.
|
||||
|
||||
The three loose helpers are unrelated to validation: `GenerateSalt(size)` returns `size` random bytes and errors below 16; `SecureCompare(a, b)` is the same XOR loop as `secure.SecureCompare`; `ZeroBytes(b)` is the same wipe loop as `secure.Zeroize` minus the `runtime.KeepAlive`.
|
||||
|
||||
:::warning[There is no blocklist, dictionary, or breach check]
|
||||
`Validate` enforces length and character classes only. `Passw0rd123!` passes every default rule — 12 bytes, all four classes, 84 "bits" by the internal estimator. If you care about guessability rather than shape, add a check against a common-password list or a breached-credential API. This package cannot tell you a password is bad, only that it is short or monotonous.
|
||||
:::
|
||||
|
||||
:::warning[MinEntropy is a length heuristic, and with the default MinLength it is nearly vacuous]
|
||||
`calculateEntropy` detects which of four character classes appear, sums a pool size (26 lower + 26 upper + 10 digit + 32 special = 94 at most), then computes bits-per-character by counting the bits in that integer — `floor(log2(pool)) + 1`, so 7 for the full pool — and returns `len(password) * bitsPerChar`. It is a per-character constant multiplied by a byte count. It does not measure repetition, patterns, or dictionary membership: `aaaaaaaaaaaa` scores 60 "bits". Since 12 characters clears the 50-bit default even in the lowest-scoring case, the entropy gate essentially never fires beyond what `MinLength` already rejected. Do not present its number to users as a strength meter.
|
||||
:::
|
||||
|
||||
:::warning[Length limits are counted in bytes, not characters]
|
||||
`Validate` compares `len(password)`, the byte length. A 12-character password made of multi-byte runes (accents, CJK, emoji) can be 24–48 bytes and may trip `MaxLength`, while `MinLength: 12` is satisfied by as few as 3 emoji. The character-class loop, in contrast, iterates properly over runes via `for _, ch := range string(password)`. If your users type non-ASCII, either raise `MaxLength` or count runes before calling.
|
||||
:::
|
||||
|
||||
:::note[Normalize before validating and before hashing]
|
||||
There is no Unicode normalization anywhere in this package or in `argon2`. The same typed password can produce different byte sequences (NFC vs NFD) depending on the client's input method, and a hash derived from one will not verify the other. If you accept non-ASCII passwords, normalize to a fixed form (NFKC is the usual choice) at the edge, before both `Validate` and `DeriveKey`.
|
||||
:::
|
||||
|
||||
## subtle random
|
||||
|
||||
`github.com/sonr-io/crypto/subtle/random` is nine lines of code with two functions:
|
||||
|
||||
| Function | Behaviour |
|
||||
| --- | --- |
|
||||
| `GetRandomBytes(n uint32) []byte` | Allocates `n` bytes and fills them from `crypto/rand.Read` |
|
||||
| `GetRandomUint32() uint32` | `binary.BigEndian.Uint32(GetRandomBytes(4))` |
|
||||
|
||||
:::danger[These functions panic on randomness failure]
|
||||
Neither returns an error. `GetRandomBytes` handles a failed `rand.Read` with `panic(err)`, annotated `// out of randomness, should never happen`. `GetRandomUint32` inherits that panic.
|
||||
|
||||
On Linux with a modern kernel, `crypto/rand.Read` failing is genuinely close to impossible, so the assumption usually holds — but "usually" is the operative word: a panic in a library function is an unrecoverable crash of whichever goroutine calls it, and you cannot handle it at the call site. In any long-lived service, prefer `secure.SecureRandom(buf)`, which returns a wrapped error, or call `crypto/rand.Read` directly. Reserve `subtle/random` for tests and for code paths where a crash is an acceptable response to a broken CSPRNG.
|
||||
:::
|
||||
|
||||
Inside this repository, `random` is used by test code (for example the AES-SIV tests) rather than by production paths — which is roughly the right scope for a panicking API.
|
||||
|
||||
## Duplicated helpers
|
||||
|
||||
The same two primitives are implemented three and four times over. They are not identical, and it matters which you call.
|
||||
|
||||
| Primitive | Implementations | Prefer |
|
||||
| --- | --- | --- |
|
||||
| Constant-time compare | `secure.SecureCompare`, `password.SecureCompare`, `salt`'s internal `constantTimeCompare` (via `Salt.Equal`), `argon2.CompareHashes` | **`argon2.CompareHashes`** |
|
||||
| Zero a byte slice | `secure.Zeroize`, `password.ZeroBytes`, `Salt.Clear` | **`secure.Zeroize`** |
|
||||
| Generate a salt | `salt.Generate`, `argon2.(*KDF).GenerateSalt`, `password.GenerateSalt` | **`salt.Generate`** / **`GenerateDefault`** |
|
||||
| Random bytes | `secure.SecureRandom`, `random.GetRandomBytes` | **`secure.SecureRandom`** |
|
||||
|
||||
:::warning[Prefer the crypto/subtle-backed comparison]
|
||||
`argon2.CompareHashes` delegates to `crypto/subtle.ConstantTimeCompare`, which the Go team maintains and documents as constant-time. `secure.SecureCompare`, `password.SecureCompare`, and `salt`'s `constantTimeCompare` are three copies of the same hand-written XOR-accumulate loop. The loop is the textbook shape and is very likely constant-time as compiled today, but it carries no guarantee from the compiler and gets no attention from anyone tracking Go's optimizer. There is no reason to prefer a hand-rolled copy over the standard library's.
|
||||
|
||||
All four variants short-circuit on a length mismatch, so **none** of them hides the length of the secret. That is fine for fixed-width comparisons (32-byte keys, 16-byte tags) and wrong for variable-length inputs; if length is sensitive, hash both sides to a fixed width first and compare the digests.
|
||||
:::
|
||||
|
||||
For salt generation, `salt.Generate` is the strictest: it enforces the 16-byte floor *and* a 1024-byte ceiling. `password.GenerateSalt` enforces only the 16-byte floor, and `(*KDF).GenerateSalt` enforces nothing beyond using the configured `SaltLength`.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
|
||||
Argon2id presets and the HKDF/X25519 layer that consume these salts and randomness.
|
||||
</Card>
|
||||
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
|
||||
AES-256-GCM — the consumer of the 32-byte keys you are trying to keep short-lived.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
Reference in New Issue
Block a user