Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ jobs:

- name: FuzzX25519IdentityFromPasswordWithParameters
run: go test -fuzz=FuzzX25519IdentityFromPasswordWithParameters -run=XXX -fuzztime=1m -v .

- name: FuzzHybridIdentityFromKey
run: go test -fuzz=FuzzHybridIdentityFromKey -run=XXX -fuzztime=1m -v .

- name: FuzzHybridIdentityFromPasswordWithParameters
run: go test -fuzz=FuzzHybridIdentityFromPasswordWithParameters -run=XXX -fuzztime=1m -v .
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

[![Go Reference](https://pkg.go.dev/badge/github.com/awnumar/agekd.svg)](https://pkg.go.dev/github.com/awnumar/agekd) [![Go workflow](https://github.com/awnumar/agekd/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/awnumar/agekd/actions/workflows/go.yml)

AgeKD is a Go library that can be used to derive [`age`](https://github.com/FiloSottile/age) X25519 identities deterministically from keys or passwords.

This package **does not** provide a CLI. If you need that functionality, check out [age-keygen-deterministic](https://github.com/keisentraut/age-keygen-deterministic).
AgeKD is a Go library that can be used to derive [`age`](https://github.com/FiloSottile/age) identities deterministically from keys or passwords.

See the upstream `age` [documentation](https://pkg.go.dev/filippo.io/age) for further guidance on working with `age` identities and recipients.

Expand All @@ -27,7 +25,15 @@ go get github.com/awnumar/agekd
To generate an age identity from a high-entropy key:

```go
identity, err := agekd.X25519IdentityFromKey(key, nil)
// Post-quantum secure, based on ML-KEM 768 with X25519 (X-Wing: https://eprint.iacr.org/2024/039)
identity, err := agekd.HybridIdentityFromKey(key, nil)
if err != nil {
// handle error
}
_ = identity // *age.HybridIdentity

// Not post-quantum secure, based on X25519
identity, err = agekd.X25519IdentityFromKey(key, nil)
if err != nil {
// handle error
}
Expand All @@ -37,13 +43,13 @@ _ = identity // *age.X25519Identity
To generate multiple age identities from a single key, specify a salt:

```go
identity, err := agekd.X25519IdentityFromKey(key, []byte("hello"))
identity, err := agekd.HybridIdentityFromKey(key, []byte("hello"))
```

To generate an age identity from a password:

```go
identity, err := agekd.X25519IdentityFromPassword(password, nil)
identity, err := agekd.HybridIdentityFromPassword(password, nil)
```

The default Argon2id parameters are:
Expand All @@ -57,7 +63,7 @@ DefaultArgon2idThreads uint8 = 8
which takes ~3s per hash on an AMD 5800X3D 8-Core CPU. You can select your own parameters with:

```go
identity, err := agekd.X25519IdentityFromPasswordWithParameters(password, nil, time, memory, threads)
identity, err := agekd.HybridIdentityFromPasswordWithParameters(password, nil, time, memory, threads)
```

For guidance on Argon2id parameter selection, refer to [rfc9106](https://www.rfc-editor.org/rfc/rfc9106.html#name-parameter-choice).
Expand All @@ -66,4 +72,4 @@ For guidance on Argon2id parameter selection, refer to [rfc9106](https://www.rfc

Unless otherwise specified within a file, this code is distributed under the [MIT license](/LICENSE).

The [`bech32`](/bech32/) package was copied verbatim from https://github.com/FiloSottile/age/tree/v1.2.0/internal/bech32
The [`bech32`](/bech32/) package was copied verbatim from https://github.com/FiloSottile/age/tree/v1.3.1/internal/bech32
2 changes: 1 addition & 1 deletion bech32/bech32.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func polymod(values []byte) uint32 {
top := chk >> 25
chk = (chk & 0x1ffffff) << 5
chk = chk ^ uint32(v)
for i := 0; i < 5; i++ {
for i := range 5 {
bit := top >> i & 1
if bit == 1 {
chk ^= generator[i]
Expand Down
12 changes: 12 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package agekd

const (
DefaultArgon2idTime uint32 = 4
DefaultArgon2idMemory uint32 = 6291456 // KiB = 6 GiB
DefaultArgon2idThreads uint8 = 8

kdfLabelX25519 = "github.com/awnumar/agekd"
kdfLabelHybrid = "github.com/awnumar/agekd.hybrid"

hybridSecretKeySize = 32
)
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
module github.com/awnumar/agekd

go 1.23.2
go 1.24.0

require (
filippo.io/age v1.2.1
golang.org/x/crypto v0.32.0
filippo.io/age v1.3.1
filippo.io/hpke v0.4.0
golang.org/x/crypto v0.46.0
)

require golang.org/x/sys v0.29.0 // indirect
require golang.org/x/sys v0.39.0 // indirect
18 changes: 10 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
63 changes: 63 additions & 0 deletions hybrid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package agekd

import (
"crypto/sha256"
"fmt"
"io"

"filippo.io/age"
"filippo.io/hpke"
"github.com/awnumar/agekd/bech32"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
)

// HybridIdentityFromKey derives a hybrid age MLKEM768X25519 identity from a high-entropy key. Callers are responsible for
// ensuring that the provided key is suitably generated, e.g. 32 bytes read from crypto/rand.
func HybridIdentityFromKey(key, salt []byte) (*age.HybridIdentity, error) {
uniformSalt := sha256.Sum256(salt)
kdf := hkdf.New(sha256.New, key, uniformSalt[:], []byte(kdfLabelHybrid))
secretKey := make([]byte, hybridSecretKeySize)
if _, err := io.ReadFull(kdf, secretKey); err != nil {
return nil, fmt.Errorf("failed to read randomness from hkdf: %w", err)
}
return newHybridIdentityFromSecretKey(secretKey)
}

// HybridIdentityFromPassword derives a hybrid age MLKEM768X25519 identity from a password using Argon2id, with strong default parameters.
func HybridIdentityFromPassword(password, salt []byte) (*age.HybridIdentity, error) {
return HybridIdentityFromPasswordWithParameters(password, salt, DefaultArgon2idTime, DefaultArgon2idMemory, DefaultArgon2idThreads)
}

// HybridIdentityFromPasswordWithParameters derives a hybrid age MLKEM768X25519 identity from a password, with custom Argon2id parameters.
func HybridIdentityFromPasswordWithParameters(password, salt []byte, argon2idTime, argon2idMemory uint32, argon2idThreads uint8) (*age.HybridIdentity, error) {
return HybridIdentityFromKey(argon2.IDKey(password, salt, argon2idTime, argon2idMemory, argon2idThreads, hybridSecretKeySize), salt)
}

// newHybridIdentityFromScalar returns a new HybridIdentity from a raw 32 byte secret key.
//
// Age does not provide a method to construct a HybridIdentity using a secret key, so the
// workaround we apply here is to create an encoded string key and ask age to parse it into
// its own *age.HybridIdentity type.
//
// Based on:
// - https://github.com/FiloSottile/age/blob/v1.3.1/pq.go
// - https://github.com/FiloSottile/hpke/blob/v0.4.0/pq.go
func newHybridIdentityFromSecretKey(secretKey []byte) (*age.HybridIdentity, error) {
if len(secretKey) != hybridSecretKeySize {
return nil, fmt.Errorf("invalid hybrid secret key")
}
privateKey, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
if err != nil {
return nil, fmt.Errorf("failed to create MLKEM768X25519 private key: %w", err)
}
privateKeyBytes, err := privateKey.Bytes()
if err != nil {
return nil, fmt.Errorf("failed to unmarshal MLKEM768X25519 private key: %w", err)
}
identity, err := bech32.Encode("AGE-SECRET-KEY-PQ-", privateKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to bech32 encode private key")
}
return age.ParseHybridIdentity(identity)
}
Loading