Key Derivation
Argon2id password stretching with exact preset parameters and PHC hash encoding, plus the subtle package's HKDF, hash/curve name mapping, and X25519 ECDH.
Two packages, two different jobs, and picking the wrong one is the most common mistake in this layer.
argon2takes 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.subtletakes 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.
LightConfig is described in source as “lighter parameters for testing”. Use it in tests and CI, not for real credentials.
Timeuint32
Number of Argon2 passes over memory. Must be >= 1.
uint321Memoryuint32
Memory cost in kibibytes. ValidateConfig requires >= 8192 (8 MiB).
uint3265536Parallelismuint8
Number of lanes/threads. Must be >= 1.
uint84SaltLengthuint32
Size of salts produced by GenerateSalt. Must be >= 8. Not enforced on salts you pass to DeriveKey yourself.
uint3232KeyLengthuint32
Output key length in bytes. Must be >= 16.
uint3232ValidateConfig 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):
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:
$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:
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.
argon2 caveats
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.
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() |
ComputeHKDF
func ComputeHKDF(hashAlg string, key, salt, info []byte, tagSize uint32) ([]byte, error)
hashAlgstring
One of SHA1, SHA224, SHA256, SHA384, SHA512. Anything else errors with 'hkdf: invalid hash algorithm'.
stringkey[]byte
Input keying material (IKM). Its length is NOT validated — an empty key is accepted.
[]bytesalt?[]byte
Optional. If empty or nil it is replaced by a zero-filled slice of the hash's digest size, per RFC 5869.
[]byteinfo?[]byte
Context/application binding. Use a distinct, versioned label per derived key.
[]bytetagSizeuint32
Output length in bytes. Must be >= 10 ('tag size too small') and <= 255 * digestSize ('tag size too big').
uint32The 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.
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:
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