diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 2ba154d..90b281c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 . diff --git a/README.md b/README.md index 93c8a2c..5d286e6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 } @@ -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: @@ -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). @@ -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 diff --git a/bech32/bech32.go b/bech32/bech32.go index 6f8a48c..a5d8330 100644 --- a/bech32/bech32.go +++ b/bech32/bech32.go @@ -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] diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..6444c26 --- /dev/null +++ b/constants.go @@ -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 +) diff --git a/go.mod b/go.mod index 82f67d4..7869e29 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 4d34556..d58591c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hybrid.go b/hybrid.go new file mode 100644 index 0000000..d886f2e --- /dev/null +++ b/hybrid.go @@ -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) +} diff --git a/hybrid_test.go b/hybrid_test.go new file mode 100644 index 0000000..6c3652a --- /dev/null +++ b/hybrid_test.go @@ -0,0 +1,232 @@ +package agekd + +import ( + "bytes" + "io" + "testing" + + "filippo.io/age" + "golang.org/x/crypto/argon2" +) + +func TestHybridIdentityFromKey(t *testing.T) { + testCases := []struct { + key []byte + salt []byte + expID string + expRcp string + }{ + { + key: []byte{}, + salt: []byte{}, + expID: "AGE-SECRET-KEY-PQ-1N0TK5J445CYGYYFLQ4TMY6JSUM7RQHTKQWV5CZ8XJTJC3D8N4NWSZNHWPP", + expRcp: "age1pq1ce7gvcvhsv0s4zcqg57pavnqqrwpncnnzcugchu8n96qcnyqgsfx472symglxmtwzpqqfcpzkgrv8r8j8srf2anm29h8jfvhghszyg2pdjs79flyw3q5tfemky2g7y34vq9qey7cmqqde96ng8fklhjfkhjsjlfsuscqaqszuqqxaujk8044yzu883e87wqy3u2hc3nszxgsrsfuxqqefnxd9tyctueywrkuegnv99lg3j2lznuskudecdrzh93ljxsv6vcgckr40vwkps5jglpl74s07qntxwjz3ylx8snu035df9dehfy6nskym8up29epjxm8kwavckus5wzcuykm4t88evccfqr6am9q3r3rt4tnjgj4enxsqwpqhd2247t3hejfjt3xyqun0qcxy7v70pzsdz5yps240t2e3fydlfdgk3m9gl6u8c5xrvmdx8xxc6afmqacrj28fqe3fpegts3d55mfreut86sqw430jm9hrddrdyuvnjget78gg39t5pvw2pfsajqljyp6pyegxls9ye3dkt8x5gzwjd4mqhd9yxdntqrxkvea86c057z9jndppx80cj5u4qsrm6vtppe9kuuukrht8sqees44x44fkytx5ux5cdlev4c4czavcdq5h66juqs4r68239aucxar9ussenut7jzkwasa2e2l2w5vlfxz73px7uxxt7gut20n62slpk4r95q5vl7t82cdzrfjt9psujzvz8jhznzrf4s5txk2kfqt5l96kky6u3rq489xhqx457s8rsyapvmql4uhc4lzsuupa8prysgeaj9329cc58lp49046g60ndtxdc5n3rdh2fuusn9g9sqtpwexq6ze2efhn8exzzjp8pm64at7e0xyqd0gkqfc433a9jf74as79fueweend0293d7vvx2ha57y92uuspu3nnqzwpkxegtmcewyn8pz08gucgthk6vmjfqdc5d0r0d6aj88jveh89xgewud8jggth5ez7r2k64p8jpy9dyua39ygjkq7mjvpaq0qe8r0jjhfjr4y4utnsnxhapj3jmpz23z5xeyjwy4jsvh4e4h28hrw9uvrvqnk5fr2u4rtn6td3k0hdzmaw3pdserc6mv8ugeq05szpl27vwg6eaf70hv9d7yjmkmqdxk49m737jrel4t0rarjw9k6uq60cgz72kfx9sm2q6dcrzas79e3q2pj2y8pdjcc0l45agz7z3v75rk6g63ue9hpzqvw89ju3yanatkk2nql9hsyqgtyte9afzeup3q0uu47lrsg58m5ggxkj0tx892f9psyrgqpy73z93q0gqchjj03c2v82x5duh2005lfgwelymqtgvtp8lfp298hvlfxw8kq2uakd54y9d9tltax683pxynz45gawu6w69zpkgxsvac5cjnwc27nte3k284rhdavx066g49e36apyf6x33s4ylpyerx4q840krvly7tfmw60zn9n39eut9gtzpesy5fk84xycwxy32eypyelkaezvcmnckvw5lkvj0vu2s4up4rg93qus34k580se8ea3g649c8yem6d6mu5racttdahy43p95vqkmej7xjtdqfz4fpkpj05tpdaf7try4ryf68sg5etq8m02t4qpjktkquy6g4z038mjztuzws5qtxzzk6j8a3v3uvtd8y0v4asqqgs59jszs0vqugeftfjkxrrw5vegzenz9kdwetn2x03ygepfxxpzvqjum8gcd5ccm55u2s4gpq8faycmn0wvma4fg4hcn5022f0pwh9ec7j2kzrv86pfpk45df83739lu3jcs0kxw6qnmcl6zjlgn70cy369tkpgdk06gqqhrzcw27ngjnkuthn2r4jfuz7xtu5znwvhkv9pnlvq0s7lxfldmzsrdxk6e0tm2kdzwxt5xh2uw59skw", + }, + { + key: nil, + salt: nil, + expID: "AGE-SECRET-KEY-PQ-1N0TK5J445CYGYYFLQ4TMY6JSUM7RQHTKQWV5CZ8XJTJC3D8N4NWSZNHWPP", + expRcp: "age1pq1ce7gvcvhsv0s4zcqg57pavnqqrwpncnnzcugchu8n96qcnyqgsfx472symglxmtwzpqqfcpzkgrv8r8j8srf2anm29h8jfvhghszyg2pdjs79flyw3q5tfemky2g7y34vq9qey7cmqqde96ng8fklhjfkhjsjlfsuscqaqszuqqxaujk8044yzu883e87wqy3u2hc3nszxgsrsfuxqqefnxd9tyctueywrkuegnv99lg3j2lznuskudecdrzh93ljxsv6vcgckr40vwkps5jglpl74s07qntxwjz3ylx8snu035df9dehfy6nskym8up29epjxm8kwavckus5wzcuykm4t88evccfqr6am9q3r3rt4tnjgj4enxsqwpqhd2247t3hejfjt3xyqun0qcxy7v70pzsdz5yps240t2e3fydlfdgk3m9gl6u8c5xrvmdx8xxc6afmqacrj28fqe3fpegts3d55mfreut86sqw430jm9hrddrdyuvnjget78gg39t5pvw2pfsajqljyp6pyegxls9ye3dkt8x5gzwjd4mqhd9yxdntqrxkvea86c057z9jndppx80cj5u4qsrm6vtppe9kuuukrht8sqees44x44fkytx5ux5cdlev4c4czavcdq5h66juqs4r68239aucxar9ussenut7jzkwasa2e2l2w5vlfxz73px7uxxt7gut20n62slpk4r95q5vl7t82cdzrfjt9psujzvz8jhznzrf4s5txk2kfqt5l96kky6u3rq489xhqx457s8rsyapvmql4uhc4lzsuupa8prysgeaj9329cc58lp49046g60ndtxdc5n3rdh2fuusn9g9sqtpwexq6ze2efhn8exzzjp8pm64at7e0xyqd0gkqfc433a9jf74as79fueweend0293d7vvx2ha57y92uuspu3nnqzwpkxegtmcewyn8pz08gucgthk6vmjfqdc5d0r0d6aj88jveh89xgewud8jggth5ez7r2k64p8jpy9dyua39ygjkq7mjvpaq0qe8r0jjhfjr4y4utnsnxhapj3jmpz23z5xeyjwy4jsvh4e4h28hrw9uvrvqnk5fr2u4rtn6td3k0hdzmaw3pdserc6mv8ugeq05szpl27vwg6eaf70hv9d7yjmkmqdxk49m737jrel4t0rarjw9k6uq60cgz72kfx9sm2q6dcrzas79e3q2pj2y8pdjcc0l45agz7z3v75rk6g63ue9hpzqvw89ju3yanatkk2nql9hsyqgtyte9afzeup3q0uu47lrsg58m5ggxkj0tx892f9psyrgqpy73z93q0gqchjj03c2v82x5duh2005lfgwelymqtgvtp8lfp298hvlfxw8kq2uakd54y9d9tltax683pxynz45gawu6w69zpkgxsvac5cjnwc27nte3k284rhdavx066g49e36apyf6x33s4ylpyerx4q840krvly7tfmw60zn9n39eut9gtzpesy5fk84xycwxy32eypyelkaezvcmnckvw5lkvj0vu2s4up4rg93qus34k580se8ea3g649c8yem6d6mu5racttdahy43p95vqkmej7xjtdqfz4fpkpj05tpdaf7try4ryf68sg5etq8m02t4qpjktkquy6g4z038mjztuzws5qtxzzk6j8a3v3uvtd8y0v4asqqgs59jszs0vqugeftfjkxrrw5vegzenz9kdwetn2x03ygepfxxpzvqjum8gcd5ccm55u2s4gpq8faycmn0wvma4fg4hcn5022f0pwh9ec7j2kzrv86pfpk45df83739lu3jcs0kxw6qnmcl6zjlgn70cy369tkpgdk06gqqhrzcw27ngjnkuthn2r4jfuz7xtu5znwvhkv9pnlvq0s7lxfldmzsrdxk6e0tm2kdzwxt5xh2uw59skw", + }, + { + key: []byte("hello"), + salt: nil, + expID: "AGE-SECRET-KEY-PQ-1X5QJK74UU4JKWZ8NGFXTDWCNHX5G8VJYKM3573W8FJG5RM3DVNCS9724A6", + expRcp: "age1pq1xd95x37t45pxc2ced3ndw9tz2t22f7jt8pl2jj5j2mq0c2z36zgy6s68sc8usg8f56xa7j7tr7d9nzgsjytq0g59pvj6nad956wxchvsxrmls6z95z9kd46y22trlnt8excsn2xyg3h932g735cv7u9nxq0qvxu2cww3ncphn4r3kqv8t8zn55ucmwt50qc794ncvuppd6aadpst9gmtu2yxfsg54djzns87r9jfvztswdy7eyxr944gq6e2ckfgvfwdafp56scfe7xeyfuzcshyudthlzprnqtxpc8fqcqdjm8d75sc0vmm0rvceacxqls2j893rg9wvaevn5z3jtfhg7h09gsp82rj9wtgf79jz58uwrh5zv42xwdnlppe82smryxvsy8kk8njnpu2kznpqqykfmcfewatdx9fzq42suf03pe33etjr9fy2qw5wxycjw2t8strtt698d98dspucjw98fcgmdcurqfy2e8sqgt2559qfyafg7ykmzm6f4h22czplgy30qav7yq8vv2cjnfyc2sllvmxca67u0jr0azhw9fq9y0anytefkyuvx255ntqv4yewfmm3f06q92e7ffn90ernny508qwpp0lgs67zrp98a2z8fhcsjh3fz0h5asgzt4vhp068ehavdhkd24f3wqvsk24aqgq46u9xuc0pf4uqau8ypyhd2f6gzv7seqsyjdmpgac2x5h4tafxjpz2k6gjudtsw35qev43fec9d2evv46ejk4z2r5p8fff6nny4kk58udp2fhfgcn5jtm06hes8k33yee5se6q3792larsslxhcryrruwqvf33p3e9qwq3n0p8c7sxgfn2fqxgceg8fnpned8jpmxc7uuqg42exc4g49gyc8x4rggppmtjvfxqerkj4xx7u6c4hqnzmekqxv06eym86fhzngmn8jhzc8naxed5pmwp04yg0z2sv4e22zxnzycesevvucrevzee0fusv75f2p4z49xner5hpqk94d2wczyhwglrjcnl24m25fc9pm383nkdsw3ggsy5ptvj4lkf3k2hp2sf9v7vpqx7czyh56qwkdxs9um4y66r74c7hjt70dqzhw3wuacave5jfj9pmnqn8v6vmr68scajegmfgr7944yufs506trg32mmwph975vtfrjy9y4xpxkk4ufhql5dvgzte4t77dt8xy0epd90y4dm7tswtsggntk3kw8evss8w8247pryeayxztse07kny2glqp4daa2rfgnv8fs225jr286g9u0rfe562prxjxpxpz5cqwz4xda3pmha2j2jrlzta7yw2zs65hc7edr7x4v6jczde4fssgztye0wmpn262vdq6885tevcq87tzyl5c0t7kv6vgyq0tm573adfn0tptxe7jzw04es4aagsjktyglaa28rtne8y5ftsnjkkclxusdha5zudcyfxdpeqvfn2mn59nmmmxf2tsphpwhjjc9gh8tqsgu45msag4jmwvczajgga77h0q22yskuxzqxtr4jmmm03zag69mlcjvchm4zhsfks0se8mea9z0jkpx4gdmp78q8uw7ycznskqumdnlnaz5rskpp7uz3v3yu9rh2udlxf4xuxjk3m4ujafv2hyrl9tdfp32cfn6w3mn0fa3wv026j60hs9c5759yz9ncy9jw76gwpczdfvkqhsp262h8xd39tqwmgx0h23vhsaewhevx8j9q27xwatf39mg7f2h47r9t97a5q3pajangp44quhxsszaxxpj09pqu9988zsgpu93nazud0sfv52ruhpnm59gpy8gz08tqkh5wjf39e22z0ycw9v5qsdrjhpauhra8a2qa99p84wxlrcnsr45ek7dspdvmzzreqsq6kepfmcycnpkdu0xsal84dyf7avzjy8tsr3t0repfgatxxlp", + }, + { + key: []byte("hello"), + salt: []byte("bye"), + expID: "AGE-SECRET-KEY-PQ-1UDXXVTAYDQZTLVYFC3RSUEVXP3QVC7TPU3HJHG7DZ0RQPKHSTTWQFJNEFJ", + expRcp: "age1pq106pca3xxjyxcjq2ft3maf2drxngzdcfmd08zenpzhg9yqyy4vzd4jwlv3ky8hs00h2tnx4xfpkd4j3vpvaswtrnv0vp8jjrewvge8qpfkvnsyvyghzf3nqvm3s26vyw9sv4c500uquwm8s2rkzz63ge5cgv0tp0wzpxjfk6vplap4clm4peqrpjladujedthk628uxjsrxa9jpcxg6768ypccqqczxq4x7c7rvthucyuw8zdty23xwrkq9qjcrtl65py8dg4yfm5zqmpxnam0wraj950s0qf7yyquehme0gw8p44gajhuerqn0ymdvynrerfty2qd32kdrrxgxmrwwev5x5q9z7w4sgd8y90lwf9v2hjw6982qef2d4v04yv2f5x34sqqlt6xk8v7lp0mue48d69js2kw4xm0xaqvf2wz8p7lavy2wgs5g4kwhtv772k7vmsl4np5w0y07jwg4p3ndqcl3c6k93n8exhge95qqk0qazsw5qrag6kk8y8esep030fyvfla3d3y4z3gp23f6ukr3wxjtpcyen5assk60yj0y7etvjwljq5ux3mw0fhun7m4jekvc6jtw42ay7q9lm88nz6849wnsjm3t8wyegzn4syz3yze3j3x53xsayx954mng2ew3qqs2qtjytqhffzlq4h84fz3ad2t4zq3wfwdd3h49ru3ezqt85n904dxse90wkxcvrcx6n2rdm8zchlj672yuv246kz88nf0pf3qysfgpnn5zk4cgwvq6pj7q8exmnkc8hnmyuw0y5m0ed8a6jqnqd5x4jpfjtmac2s3uf88aafdcy3n3nuhystnfzh92er6lfk3f9pssymq496k7surljnpez2jx6y5n4ljwqal9rxr9p6crmsspc9wlgpxjqrl2qzl3u5lcfvt94tvudh22v8wc3ke99f6ke8z0dph9cwtg4g5pn3r4r30n8tv88ep2uvfzl3hgtvdjevxun5g5ja2cvhpsq57kwrzs4s7ath9m72evzp8dpltegnyfg9d63vz2f5c8f065zf8de85aehqm5s4uemjgtusfnf0ycznv5s2uf2tcqvqegwjsg0y0xd9hekepgf95vk9vr3q463yev24y34ujqmrfgqva0tjx8d32qh3mrsnxsqxsacpy39dwkt30xzzjdg3wu4yjtly66224k7c7596kmz40ycwjc5yly0tdux2kgsls4rke3neqty5mhulnp5za70rsjztzk6zgefc9e6svpvt2ye8wpc3ysn4z90h32lfz5zdwxr3qgx6xxux80958nufzu294adnqc5sgdcwrw7kwzvddzmd2mlvadz5r2cvxa7j8aav888vpecm3kgf8xrgr96ffh2fyd2l72589ucxj7y2g0426ugsdkrt9r4gn6kek94ex5rsc7gjcz9rpqqxxs3wljt5ucypq4qv3dqc6pyy6f8afsu0k70zc66hfplesr23sg522uhk05sf3hgm9j63xj8t9a2mne3eqcc2hatq39r85gp4396usxfkgt6s3cvl94nzmpfxz4fhnqmx5trv92c833edqm7eyrm0xduf3wc3yvu2fd7xkuasafn4elgsqwvygkx2yfe6qm9ehuglv2krh6cwxv6tf76mc7086m4fgthrjkxtv357sasl456lvzf76k9czyt4qqpvvnvuzn4wguj4thcncdtylfvz7mr5jngmd79z5myej2n09snwcaman90xsmnsupgqvyv26jvx43u8palxuc6pz44ncfh5h5jzaewcevch0u566u4nt2uwnd2rv2lp7ymcjxpnx0gehm62a8qj406d4ls082akqr27fy3jel5l4nxx9y3mhaqgewswu3vxp0vr37g28ruy7fgu3nwl9sc8xqlhh9nsjm4vrsjjhexnxpezmklf6mgr0s485l86dpp", + }, + { + key: []byte{125, 231, 97, 121, 25, 36, 248, 109, 22, 245, 220, 7, 19, 151, 123, 246, 40, 27, 194, 4, 133, 222, 108, 216, 32, 162, 132, 16, 142, 151, 22, 104}, + salt: []byte{62, 98, 62, 226, 73, 49, 93, 5, 172, 234, 232, 145, 139, 78, 172, 4, 139, 156, 74, 57, 215, 32, 72, 216, 17, 74, 220, 250, 146, 3, 190, 254}, + expID: "AGE-SECRET-KEY-PQ-10EWK7C8T00VL22WWNUSCUKZ6JWUATKXUKY7KZJQJZCLUA0Y4NTZSXL8PUZ", + expRcp: "age1pq1k2uxhzhvy8qxp9reca59rfrvvl6vm8qyzzengyvxsuank4qqredpf6qhznvry88r932spgqutwn85y86d9k2pxym90pk0j60p0jn784v3z3lpj6zhqzk4xvpe73vdx72wxflkpnhws0434eq78vjml0yxdeaqc82fpdt22da3lf5nzhz2sg0fqllxuvw3edjnqqra9v80gglcmuuwyh7pem33y9eay592fzjc7twysrwzucwth63trrvry5k4txhv7xfwsphfgasfczqz6ykhfm2ms6p8qw95kd44m4z42wedg8st3d4chxwelvqruzjwmv4svanhjmhrqc9sswgwapxynw6pxgmfys4mw6xp33xae6mztsfe0rg2u49syfgdganrgdvkr2e8req4v6x9wjat04h5v292jjawcm0sv28rfumdavkugxuqsa042n00d8wjfr0ardpgqlj36a2xgm00r888usg7mvttddmfeh520p0lvarz2asjq2ttprm0varja6tgdh5vjahhzx8dpn9s2t0qpqss9dpuwtkqrktzdrvynr54djq438hzke7yhq5vzxrh2yxyp67ugq205gfzw9z2sxrgffaft4js6y9hfuye6t6um9jccfq0y58x4t08k4t0jjztdy9ju9acsftn3uwq7xtefg470jhkph5qdmjsja7as2jvqdvnsfj3huqq4n863gaqgdjqaprcs8pfktrctv9gakwzeuvmccz7ws45ey3qkq3qy8jfvp06uq3wjn20kkrpgyujv7gyg2vr4ccmukypz7y55ww5prxllq88wa8xq0zjh2ayfhj99xpf3dhcwysaamqna8ypj8yt2d7keg0mkp2fncxyv58tf0ya9qkhv66af2ts5yh5jf32xqmyutcn7ugt3yxhhv9h3qgcnw4xv2t83avctyknqdtkux0kk587fkajqrlykdsu3l3sp2zxsva7aftwd6qxzcp5g4js3cex88s6502yzr03p2xslm820a8gagxun9scp9yzn24sgdmfyhyqerxjk34t5qfytw9nqp8cuwx9p4q27x9q9drjl4y6zarq0a8z45w7k22ne9dxf5gypzmzm7jsf95zcd3fyjyhtk8q0h257hq0xxad3yz0av3d7dv3kn5q3qnh232ya9vzkyk9935p6m5e23h2mg03w6275522cyqejjqru6fv5k7axgpccejyeqgmfupcf9k5mmt547jz7w92hdr7em62jx78q54dpey0yfzqgdvqlmnry03ht32v5nrtmyx046p37u3rx7j4wy68y3gkyrn3z7g9zh54sfpp3g9x5yp342g97xxrn4mc9rd59fwq4zy6jr7lfyp70qjxuhmys23xryce6kdvwzk002qf3jhs67lxazl66cpcew920ee98yk3vwc54vneuemzyrmjfmvdmeqhtt80fa5t5ed8vw95cx4304d4jxplsrkc43r3gqp9y9k3g9v2g5dewyl38x2l97vpw79tuyrkah4xvxk943fhx5za532t7gq5v9fzk26pzcsdcwzlfxntw8s5af6j9k7eenz09tjmcxcecyhej052gpk2ajdwncmfhm22cpwrr3u4vpwwhrwarc2r0wsytl830ke99kxfezk5eeveyahrhenx7hkg3pqddc70cuf0676290tqvutsqyfrqpe7qtrpqf3wykxx8amkgf36pv5qnctd5yphmzqsv9jvwd63arzd0vnp27qvf0wk9ngqhr64hyku2mgzu3nctm7j70luqjv834dj8zgnxdpxw0rwaujgun3p6vl04pylt9899e49nr47j835ky5f6zpf3aye8hfxwsvmg84jlrz0vmdtx6kelx7vvkrx0gn5wmlseejwh0zev5sgzq2474udz5egxugxvdr0wku2h57g52pvgya2c9jr4j0", + }, + } + for _, c := range testCases { + id, err := HybridIdentityFromKey(c.key, c.salt) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + if id.String() != c.expID { + t.Errorf("age identity mismatch: expected '%s' got '%s'", c.expID, id.String()) + } + if id.Recipient().String() != c.expRcp { + t.Errorf("age recipient mismatch: expected '%s' got '%s'", c.expRcp, id.Recipient().String()) + } + } +} + +func TestHybridIdentityFromPassword(t *testing.T) { + testCases := []struct { + key []byte + salt []byte + expID string + expRcp string + }{ + { + key: []byte{}, + salt: []byte{}, + expID: "AGE-SECRET-KEY-PQ-1CZF7LS45MGJEP5QTC7WQYXDYMV0GDY5VPH9Y0AAYSC66957K4HWQH8YJAS", + expRcp: "age1pq1sc7xlzw5za6j28fz3x2csz9hrktk0rdqrtst5qeywyqw7kkfdq2ykltkspny2yt97pvsvhp80eyu99m3dnd5srwqwxkqevuexjpydr04qvt7jcykmvjuxlyxc7f9a8yv0e9sxva4qpwspw357k6vjha2k9tmsdl7awdf8j46xqrg6rgnhqqagtx8y0r36wv0mwa4ra2gst8r8v9f73gux25eu9jh6v7tj84adnqf320uwgzjxknpyejye9ctwkcak9h67yw2mlsjp525hq5krq3q8ffmxs9jj6gm7kkphgjj3nsvvmqyc6s7nj5468r6h85yhn2m39w4ezndsmc8wgp5xsef8fuatdf5kvps902hs3cjtzkafzyp42jqu2qvg09epwyexkvv3tr83wyg4varpq4v69zkkc2ck95anyyxu72u6cpk00d5j7xe2ps0pfd7xuqzsajymv3gcd5despn8fp2fex2c6kxex9kzjg3vc0zuu0g0tpk3h2hhcssvjh44qtk6eyhs6rvzff6zpukqcj6f0ghav99mp4ej04cgw2h4c82q6azq47r5w4h38949dhjfxgp8jx20wydlq3sqnkznmg89r44dpqf9d4yhkpdlfxyx34ge0ugzsmf26ezev4ft89pdl2xrmjexhexmqlgd9nxu0d25ax8plexwh7gguwwn92yyfdc56u6kfwycwd6j3ky6zqg6ljjkx6vpcpx52g0nzx45q7dmem46zzqkawkch76m9l936gf33jmzsppvrqrxle9l3k3n9afsud2s3r3z2qle90yyfxtkf65utavym36y5e9sfe2lpaxuawz3mr5heszp64jtzh7zdw4y45sysdmzjv80rrhz85m0y87cjf9wqayx4s4gt0s3ft3v7xz589pg06u7wktj7e7f9vtjakslzfd4fg5lrnp2crzw8jmpxgem3m0x5crwhsyc4cjw0zkspxjm0puzzsn8xntxfq99djf3xve3pc6tzykuep43ngyhgt7mpm6ggavctvymwx5qaq8z02g90985k2z2cu5hkwhztfsqnaumxne7kkv3nezhrsmel5gw3490g5j0ejwp44yj3v92m3ry35a0dlzhpqg8ast5zye8qf7sccyxcsnnaagejmmru8fq6wucs2qesluc5dt3x39u4wysyyry6z63qxnl33yjq2jzvc3sx0jz8gmtfck05uwh5gz690jpe24gnsejjpe9vqshlfh2edrfzcky28r3f4fpl9va2d5gam3s5lmkaa7xjepdcznakvv2hqttah3ysfu6fe29ycpql4grpqrgl6qzl3tjq7jvqfmhmxrmxjqwxlhtrksk55kyatusa4nedjsqptvy4ggsydwfk65zhymzn8xk7g4s8dgdga2l3av5hrtf0guwcrnyua3fj3lcpj4vn3ejcqmfq098g904q0jjq20yngqc80fxnuuyt7fsea03360hp2sm9cqrwsfqe0n4yas2zmnekw8s24e8mxn202egwasksvv2ymz7783szgnnsaem24gymzvhtj3alkhepfxy34nzxxltykgr3fp0hzcvh7jgdqkkavv4vyvc8z322efnged5gncspwf7c6lf24uarr2vzwfd9ff7e0u2x99xxduzmam2842zep0wv2kz4w8wpyqxa4zrku5gl47dvkl97tuxyy2zh5x8jq65dgztyauqagyw59r34kvnrfvnfk0fpth5jdegky9te3uc9c6y3ucvayrw8quxrj9gtqezk4a33370f8ezza3qr3489052r4pe89dwvfn7c68k4zkjdyxgur4vmjgj38g708r0t5gkccctg0ttctrdp80f5asdff4prads4hu69dk43yqedmd5txmgnrnx5eueg93mkk69swnsvtg6qrw7scjc28jhfuwxg7grcdhkfav", + }, + { + key: nil, + salt: nil, + expID: "AGE-SECRET-KEY-PQ-1CZF7LS45MGJEP5QTC7WQYXDYMV0GDY5VPH9Y0AAYSC66957K4HWQH8YJAS", + expRcp: "age1pq1sc7xlzw5za6j28fz3x2csz9hrktk0rdqrtst5qeywyqw7kkfdq2ykltkspny2yt97pvsvhp80eyu99m3dnd5srwqwxkqevuexjpydr04qvt7jcykmvjuxlyxc7f9a8yv0e9sxva4qpwspw357k6vjha2k9tmsdl7awdf8j46xqrg6rgnhqqagtx8y0r36wv0mwa4ra2gst8r8v9f73gux25eu9jh6v7tj84adnqf320uwgzjxknpyejye9ctwkcak9h67yw2mlsjp525hq5krq3q8ffmxs9jj6gm7kkphgjj3nsvvmqyc6s7nj5468r6h85yhn2m39w4ezndsmc8wgp5xsef8fuatdf5kvps902hs3cjtzkafzyp42jqu2qvg09epwyexkvv3tr83wyg4varpq4v69zkkc2ck95anyyxu72u6cpk00d5j7xe2ps0pfd7xuqzsajymv3gcd5despn8fp2fex2c6kxex9kzjg3vc0zuu0g0tpk3h2hhcssvjh44qtk6eyhs6rvzff6zpukqcj6f0ghav99mp4ej04cgw2h4c82q6azq47r5w4h38949dhjfxgp8jx20wydlq3sqnkznmg89r44dpqf9d4yhkpdlfxyx34ge0ugzsmf26ezev4ft89pdl2xrmjexhexmqlgd9nxu0d25ax8plexwh7gguwwn92yyfdc56u6kfwycwd6j3ky6zqg6ljjkx6vpcpx52g0nzx45q7dmem46zzqkawkch76m9l936gf33jmzsppvrqrxle9l3k3n9afsud2s3r3z2qle90yyfxtkf65utavym36y5e9sfe2lpaxuawz3mr5heszp64jtzh7zdw4y45sysdmzjv80rrhz85m0y87cjf9wqayx4s4gt0s3ft3v7xz589pg06u7wktj7e7f9vtjakslzfd4fg5lrnp2crzw8jmpxgem3m0x5crwhsyc4cjw0zkspxjm0puzzsn8xntxfq99djf3xve3pc6tzykuep43ngyhgt7mpm6ggavctvymwx5qaq8z02g90985k2z2cu5hkwhztfsqnaumxne7kkv3nezhrsmel5gw3490g5j0ejwp44yj3v92m3ry35a0dlzhpqg8ast5zye8qf7sccyxcsnnaagejmmru8fq6wucs2qesluc5dt3x39u4wysyyry6z63qxnl33yjq2jzvc3sx0jz8gmtfck05uwh5gz690jpe24gnsejjpe9vqshlfh2edrfzcky28r3f4fpl9va2d5gam3s5lmkaa7xjepdcznakvv2hqttah3ysfu6fe29ycpql4grpqrgl6qzl3tjq7jvqfmhmxrmxjqwxlhtrksk55kyatusa4nedjsqptvy4ggsydwfk65zhymzn8xk7g4s8dgdga2l3av5hrtf0guwcrnyua3fj3lcpj4vn3ejcqmfq098g904q0jjq20yngqc80fxnuuyt7fsea03360hp2sm9cqrwsfqe0n4yas2zmnekw8s24e8mxn202egwasksvv2ymz7783szgnnsaem24gymzvhtj3alkhepfxy34nzxxltykgr3fp0hzcvh7jgdqkkavv4vyvc8z322efnged5gncspwf7c6lf24uarr2vzwfd9ff7e0u2x99xxduzmam2842zep0wv2kz4w8wpyqxa4zrku5gl47dvkl97tuxyy2zh5x8jq65dgztyauqagyw59r34kvnrfvnfk0fpth5jdegky9te3uc9c6y3ucvayrw8quxrj9gtqezk4a33370f8ezza3qr3489052r4pe89dwvfn7c68k4zkjdyxgur4vmjgj38g708r0t5gkccctg0ttctrdp80f5asdff4prads4hu69dk43yqedmd5txmgnrnx5eueg93mkk69swnsvtg6qrw7scjc28jhfuwxg7grcdhkfav", + }, + { + key: []byte("hello"), + salt: nil, + expID: "AGE-SECRET-KEY-PQ-1FLHUXKNCY75FEDEVT6204RZ6XPQTKTMQX6UR7L6TZ52WSAKYHSLS3YGJ9E", + expRcp: "age1pq1j0n3hzl2t5ydg9zj49pt2ycuhfp6gzkx5yhhw0ukx9hq27gud6mcr9t6cx3m0806ssyadvfma4uj6jkg08q5v0g0jx9nesqldmwtv5tkg0z5rn4wucs02qt80d2ukmmycde5wuchx30v0kdc8g4eg0lpjrneqpud3p47y4sj65n9m4tqkpqatzj8pgw0gytadncq7ezv8ny7ya9kxn98l7thzlsmfuj82xavc042sfxpfsxqth22er5ptyqltfu2l2pawrp0kcpvudrrcqfdch7gwgqk66uprzfrk349py735rrcnwm2mf4a7czzen58dc3gzfea9qscsxj6vc24r4w3se2ag9j7x3fa06j4cr5n26d50w5ap2su8s7ht4fk7ptxthg682ywcknhu99e06cfwdsz5c989hynkqp0j2npv2fctuzm8j7u8ka3srdnef355qfzz622hd24e42uxtrs7vq9pxm52wsxykr3hn5cqmcqrpxg0kq4eceqeey4dgmlqxyn9fv2juttj3f9skp3gyfkkw4jccrtr4f6q9s52vkk43yxkhtdj6xpy95gg72x32ps4e96dvz2sshmcynd2zrxxqsrr7p69nvyq73eyss6u543gztmgaulcp8zwymfsdcce2v9kva20kjw5g0r62xmz2pk7lryf0ujvtdt5pfn3vcvg6xr335zqk9hszdkgu7mkvklgap0qrx2ytupzflqyfvzvdmgjc9d8yek5n36wlaw2qvy4wzrpc98pu44y7xhz00dcss3y4hsruq629zxjapmremcz6drs49my3esxmek4fae5wcjrsntk76s57scscu8fvg24hr9dfhhchrjnpngdqgh6x0zqc5q8jezeqva9gjqnzpsq4gz9unjtspwuvwfgnpat8f54yhk598swnpe39a52guklk2qfw0uf7ruxrqwdggf4mr7km3q408cvrl6tpek5vulkutx3cnsrnz849hp2naul2mg9lqe35ffmjuqrj4f0nnjhxqse6axvhwq5gvznuludd886stxypg6j5a4ygxn9f7ktp4kdjl25qafu8ztqzdfqcyjc7z89sqhseycx3cehfaxttv3wp0zvvnfvjzayp7q4qvgw5euqx2a7yy9q3agthf6y3jgel0cjavh72t4gczewwmpyrk2gdtkkvjkfvzslcm74kysn5f589sf5372fy2833wphathl6kcq8q5x3prvk99ddqmk8qfu9qu3km2hxtjwqng57a6z8787uds0hulnju0m3l4p9d3fzvha2sqqt3nyquw9v50kepx9jkqkfyp0w4kckscxy0m63wytcqq8r06r8yyzlnqd92gsae2rk4hk3vsgzuav9jj5jysuwg4h96mqtg2p2e09v3h9nz6a2zp7n2e78urx8xlzgk7dgx5hp6ywaxzww4es0tyqe7pw7qjeuftljq4ta95scnqpge62z4tsefhzmtqyt22re8ynvf7uu3ynwe9yx96hnt6hdmc8dcz7pqzqs7dhctplrq8cedhetwu0tphw3gkkgx2davn9qt44p4c2qfhhmqa9sru7p8msd2qzs3n5u4rstrhvd636zzyx0lxp32hexf5ffqdqv6n3htsr0ngg6p0qgamtfvr4g3hc0yeddx056dxn35kk2ddhjpxpxjz4ft5xmx3dpp5kg9uge65tudpqjy3sc08vgq7p7muefyyaw3vq2px8xn933udgfprm8wvg6p24yj5tq4vsgewu62mfwdgq3afxnaw5etvlxnn7zvtf9ympt594x6u484lykpn0s638x5c2w5qsmacz8acx3csayjkrwhyz2ywptqcs3w7pas24snkeyfhynfqg2xjtkthj9umxjsd7eq59egjawk7e9geg0c0vq5w79hr3ce786jqfvjc8jqk9fn5g57h2yud", + }, + { + key: []byte("hello"), + salt: []byte("bye"), + expID: "AGE-SECRET-KEY-PQ-1UJLUV5F6PYHUYSJGLXWANLD96W9DG6MY6F9ZJ4HVMWT3ZMVP5QLSUD0AU3", + expRcp: "age1pq1akarat45xm0zqw93ypn7w7qedc7t7e6g2qws98a5zyrecyutz4fp3eazv2n2505ukaxmtxsnyxqyp2rf3u5dj88cldzlkj7zu254rugnng3ezl3g49hfc7urk7u3d4cz2t7pq7wuvem6r5zd7p4nvsm6vzx54dk2qfern6nd8syjnmgfrkh4skn7gkaqk5zkhzjuqvsshsl7x2sx26znx246fgnqstxcprve88dsk20mdkq328ssavuqkwx0jf9ejatulwc7a8sf3na8zlxz8nz9sdqk7eff80gzz9dxtcs343tzgugycvz85mzzqsrkv33jh0qenfnuuz6vdkprvs9kqwcnst07mvnpkv9gf0yhs4cp26sgrzq7wu4z03fqq69y0td2zjyysurd0fm5u4wfhg7972yrgfm3g9ghzyfyz55y5ju93rxvcugy83wm49wcwdxdkpfgd48f9qykh09aag72ckpwlwz9e5lthrmm5zdc7de7qsj4lns3mwtt2skne89a2ddhv3salvvhpdxhe4jy5wh92x5vx6gcpvnmhpuv4mp2pql9ucn0euvcxrg8wwlypjqjen8v27v45ge470dql57mq72mgvzruj7z4xtxhy4zpkn2jytjks0gepwtz6aj3t5xy9rqgf5xgl5m546v64evds6sjjukn94zc63t3swle8rmaxzxlr3mk7srj57luz3q579cr83nwpvvz5ky3ryv8v7tqg65cupe5ywss5j9fqvrk33e3qr8n2s99vzygskx502zhfm45pzd09y4cjyvn7f5pj7mgkyhgvxtttd8gps50chj3x52wye3ty4n6aafsj64jrtgc9yp5ymyr4fk3jrcqyf9tp04zeeqzxf0upp8h9qqu62230e4hp407mzyhf67a7mjkz59rukrefur26zxatzmhjk9f6t3e2w8y65jxmp7mmz65h9uyvnxx59a50mcavzsc92487p6nry8drw6pp23jp4df2ae324j0fwswsm6yv03u67pqkequ43qawzvw49nckmr25xnuxtzd8rtfx7cfe0lcmh5r2clsy3l0psewjdvyxj3y00k65ylw5jkq33xtlcurgf9xrn7pjqme49qg5ps6xzqhamh4xwpuf89pxdl4a665zcxk85x83mpzxflz2d2jv46s8398lhwya6zm8rumsp8r4um9ze8wsmzfrzqjs70wcze09zgjefmfjwewfmvcack42d0ulqn4d7r4x270f48ryc8l24kdw7v9hszwegxw9fcu79cps6357g873rk96dr03sqy9ewxa5ehfmezh82e96yfj6twasuxafs3rn83qpy8w04jajq6hxdq4adze69vtanqw87rgmgty65jjp60dyxq57hh93uvqthjjdskug4spejpzj4xuxeh379uu52377tjw5jjdrkcyv65ah0xvajyszsr8r3kx5h9skpgktjzqghxv3y5h5g0axp0dyu9sr0jnygledlxj9spgun2peunsz4scf5tyau9w54thne0j5q8vetvkeh4aplc8mvd4jt87gqs2hz2dalxpphrqt3mgkf2dy28dducm8c8p37v62nvn8pjnrmwp8w8skkwjpfll2clvvxexscy59adjyzdkqv9ya5pheq56mjv00sqlysxtzkqw9fvgtfgltef92scpev6jsw6st5m09s9h5g49qux527lf2e4pg8y8ljdqx7wkd4uezwf9tvf6m8r2ckhmcnk5fjrwyxvjaak5fg866qqtaxygwup2ea8cf2tr2zqjue34vhcy7wfwlg53nxhzjmhuane6ccz27slgeunmswrcyelyndmzk309ffl7qhefx8hkf7raecavampq3g80scnavq79hqy077jgm98hl4k3v3haxzs89nx3s9y682cxv5sg79m2uytq7yvf2k", + }, + { + key: []byte{125, 231, 97, 121, 25, 36, 248, 109, 22, 245, 220, 7, 19, 151, 123, 246, 40, 27, 194, 4, 133, 222, 108, 216, 32, 162, 132, 16, 142, 151, 22, 104}, + salt: []byte{62, 98, 62, 226, 73, 49, 93, 5, 172, 234, 232, 145, 139, 78, 172, 4, 139, 156, 74, 57, 215, 32, 72, 216, 17, 74, 220, 250, 146, 3, 190, 254}, + expID: "AGE-SECRET-KEY-PQ-1A9NREP6ZC3QW6958RA49XD5MYGUQPHJFLQ0SCLN74RYGWVNMNEVSY8Z3ML", + expRcp: "age1pq104mpkpquwl6zgt8mc2enxykt6qtyezkgkwrgtymly6srkxf7ghp8n2ygvyyverzr4xustpnanu7phzz55tdw2chnpz6tyd38d0rhdn6tyfujwm8tl29m73ss2umxm25hg5vmhxghw99yn4f83e2e32s3dmw0p0vxwkkvqz2kq62u6rnmrnc6wg8ygfy7fac9g6wfya66kurzj0zf93x8946t5rpkpmhmes9snsvmgr8yrgrvpgkgjzr55pv3yyel25nynya97nkpcsgx34f2nrawx3y5t72p22fmluu6k55f50whmyf5y79axk32uwjfhvh98rtw5etknwwrd5r535kftatt8fvyaja6mjaeey4y90255zvs438dfsf2e53xvj6cz8rxkz6w9wtsv2pzdacf5g5z6nzzt2g78rjgpv3ax7dg8ftzv5mk9lj6xfe0eyznjan3tn68cxgpek28w4q6lv92v5fhgwyjx7kvspkmjsc27km7559f0jcqp5eeyexjpq4eu34akzfm5vcccd7vventdzk7v8xwfxfu2gj6c90x88tzjdj6x3cfg6gwx2au0h9eshml8z8wtv073vdxk7f8dzzhggdlyksexd5hlgynggts7sdp3v26k6yu5gx8ma90f8nffu9f8k8e0zln5cp8nqgyery9gmvp90dagh6rvwwc5xqar2s8ap2yv2gm2rae829a2sk9zk964m22vqt623yp0r9u8dda80fn96884t072ufkqpd7u4p0mag25x84cldmpqz2u2mzy5nn5adp5t58f8hzxakne3kkasgxsl9hh0t9xjy6ju4yz29332z32re2cajkhff7gtfack5kk6sfqf44k3hjnmfzxsk9uc7lqgqhxsgvla7nsjmnslcsewwgphzefjpgumsjtyz28d2fu9wfnx4emqktlf08dv5x8nm87va3uzs04jn4cjdrkg6j0nfyg8rd46w9qnce2p9zfsfx2ylhz64uqptmu942gyzphx6kxd49w894m6tt606zn4q6vyq39yg4nz6ndgazwutknk2c23fcppjzg4j0jazdmecqjx4608u6k2623g7qhdpa3hnxp8p52uf2fx7v9xpvnvkdv5j6mfut3f2kwy56s9x355cqfe6slxcknmsfq80hjmx8sk2j6yz3x2wch5a5xztjh2r3a5xp8nmu4q6thmc6nfrervpk2e5wj8qjldav228pn9x3lzprpkurxz3thydt5f8mz5f4jzmfcu5g5etvx7fne966vmccmgtznjdu7gepytmvwqtssafnwxg7dxtk44ty44lv9maa8ztyc3nls29pl9p8fwmfyn7nhpuczyhykcayevt9kyv5v8ftshuzazsczlqkg7ru0x5y4x37sfdfgxf6nkwpvgxqh598f2jp32ps4plpe3flntz5mdugf3rq3rwvmw2aa9y3kua5apj8j3x7q6ltc4a9yxek89phx4xnpcly80733gpg5vnjp34jvcmjqlkfsthh577qawd4cxchsguev65jyd7jns7zh5dywyzs82fhde4tqlcv69y6fzr6cprz4t6tg4pf8q8g8xpq7c392hfyfaxs4s2dwdh8m9uvnvmae8deuqhcwrg6qqh0s5yrge4usxqz53x2p2kgsesnaxq4cmr7d94pet39hss99p5jezjevctv5f789yf9wtzzzwuy323ycaedadrzejz8y2y209y693tjrgads4qz29deks0jk5k92qfqkcfeuf79xl2xhzum59va73yrjw4cfav6zzxzszz730avdjdv3qru7zaytznjstvm8ffwvnykg7nvzdsk5vk9q4v89u4tm372mw8ggrygu3am49x5ddthnd5ezjgan3sc2xzle2zn654tx99ms2v8m7w78vug8y7lqrza745vsqtvrufxyg3e", + }, + } + for _, c := range testCases { + id, err := HybridIdentityFromPassword(c.key, c.salt) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + if id.String() != c.expID { + t.Errorf("age identity mismatch: expected '%s' got '%s'", c.expID, id.String()) + } + if id.Recipient().String() != c.expRcp { + t.Errorf("age recipient mismatch: expected '%s' got '%s'", c.expRcp, id.Recipient().String()) + } + id2, err := HybridIdentityFromKey(argon2.IDKey(c.key, c.salt, DefaultArgon2idTime, DefaultArgon2idMemory, DefaultArgon2idThreads, hybridSecretKeySize), c.salt) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + testHybridIdentityEquality(t, id, id2) + } +} + +func BenchmarkHybridIdentityFromKey(b *testing.B) { + for b.Loop() { + HybridIdentityFromKey(nil, nil) + } +} + +func BenchmarkHybridIdentityFromPassword(b *testing.B) { + for b.Loop() { + HybridIdentityFromPassword(nil, nil) + } +} + +func FuzzHybridIdentityFromKey(f *testing.F) { + testCases := []struct { + key []byte + salt []byte + }{ + { + key: nil, + salt: nil, + }, + { + key: []byte{}, + salt: []byte{}, + }, + { + key: []byte("hello"), + salt: []byte("salt"), + }, + } + for _, testCase := range testCases { + f.Add(testCase.key, testCase.salt) + } + f.Fuzz(func(t *testing.T, key, salt []byte) { + id, err := HybridIdentityFromKey(key, salt) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + id2, err := HybridIdentityFromKey(key, salt) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + testHybridIdentityEquality(t, id, id2) + }) +} + +func FuzzHybridIdentityFromPasswordWithParameters(f *testing.F) { + testCases := []struct { + key []byte + salt []byte + }{ + { + key: nil, + salt: nil, + }, + { + key: []byte{}, + salt: []byte{}, + }, + { + key: []byte("hello"), + salt: []byte("salt"), + }, + } + for _, testCase := range testCases { + f.Add(testCase.key, testCase.salt) + } + f.Fuzz(func(t *testing.T, password, salt []byte) { + id, err := HybridIdentityFromPasswordWithParameters(password, salt, 1, 1, 1) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + id2, err := HybridIdentityFromPasswordWithParameters(password, salt, 1, 1, 1) + if err != nil { + t.Fatalf("failed to create age identity: %v", err) + } + testHybridIdentityEquality(t, id, id2) + }) +} + +func testHybridIdentityEquality(t *testing.T, id, id2 *age.HybridIdentity) { + if id.String() != id2.String() { + t.Fatalf("private identities do not match") + } + if id.Recipient().String() != id2.Recipient().String() { + t.Fatalf("public recipients do not match") + } + + out := &bytes.Buffer{} + in, err := age.Encrypt(out, id.Recipient()) + if err != nil { + t.Fatalf("failed to init age encryption: %v", err) + } + if _, err = in.Write([]byte("hello")); err != nil { + t.Fatalf("failed to write plaintext to encrypt writer: %v", err) + } + if err := in.Close(); err != nil { + t.Fatalf("failed to close encrypt writer: %v", err) + } + + decrypted, err := age.Decrypt(out, id2) + if err != nil { + t.Fatalf("failed to init age decryption: %v", err) + } + decryptedData, err := io.ReadAll(decrypted) + if err != nil { + t.Fatalf("failed to read plaintext from decrypt reader: %v", err) + } + if string(decryptedData) != "hello" { + t.Fatalf("plaintext mismatch! expected 'hello', got '%s'", decryptedData) + } +} diff --git a/keygen.go b/x25519.go similarity index 70% rename from keygen.go rename to x25519.go index d7bdbaa..ee29c25 100644 --- a/keygen.go +++ b/x25519.go @@ -6,26 +6,19 @@ import ( "io" "strings" - "github.com/awnumar/agekd/bech32" - "filippo.io/age" + "github.com/awnumar/agekd/bech32" "golang.org/x/crypto/argon2" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" ) -const ( - DefaultArgon2idTime uint32 = 4 - DefaultArgon2idMemory uint32 = 6291456 // KiB = 6 GiB - DefaultArgon2idThreads uint8 = 8 - - kdfLabel = "github.com/awnumar/agekd" -) - -// X25519IdentityFromKey derives an age identity from a high-entropy key. Callers are responsible for -// ensuring that the provided key is suitably generated, e.g. by reading it from crypto/rand. +// X25519IdentityFromKey derives an age X25519 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. +// +// For post-quantum security, use HybridIdentityFromKey instead. func X25519IdentityFromKey(key, salt []byte) (*age.X25519Identity, error) { - kdf := hkdf.New(sha256.New, key, salt, []byte(kdfLabel)) + kdf := hkdf.New(sha256.New, key, salt, []byte(kdfLabelX25519)) secretKey := make([]byte, curve25519.ScalarSize) if _, err := io.ReadFull(kdf, secretKey); err != nil { return nil, fmt.Errorf("failed to read randomness from hkdf: %w", err) @@ -33,12 +26,16 @@ func X25519IdentityFromKey(key, salt []byte) (*age.X25519Identity, error) { return newX25519IdentityFromScalar(secretKey) } -// X25519IdentityFromPassword derives an age identity from a password using Argon2id, with strong default parameters. +// X25519IdentityFromPassword derives an age X25519 identity from a password using Argon2id, with strong default parameters. +// +// For post-quantum security, use HybridIdentityFromPassword instead. func X25519IdentityFromPassword(password, salt []byte) (*age.X25519Identity, error) { return X25519IdentityFromPasswordWithParameters(password, salt, DefaultArgon2idTime, DefaultArgon2idMemory, DefaultArgon2idThreads) } -// X25519IdentityFromPasswordWithParameters derives an age identity from a password, with custom Argon2id parameters. +// X25519IdentityFromPasswordWithParameters derives an age X25519 identity from a password, with custom Argon2id parameters. +// +// For post-quantum security, use HybridIdentityFromPasswordWithParameters instead. func X25519IdentityFromPasswordWithParameters(password, salt []byte, argon2idTime, argon2idMemory uint32, argon2idThreads uint8) (*age.X25519Identity, error) { return newX25519IdentityFromScalar(argon2.IDKey(password, saltWithLabel(salt), argon2idTime, argon2idMemory, argon2idThreads, curve25519.ScalarSize)) } @@ -63,8 +60,8 @@ func newX25519IdentityFromScalar(secretKey []byte) (*age.X25519Identity, error) // saltWithLabel appends the bound kdfLabel to the provided salt. func saltWithLabel(salt []byte) []byte { - s := make([]byte, 0, len(salt)+len(kdfLabel)) + s := make([]byte, 0, len(salt)+len(kdfLabelX25519)) s = append(s, salt...) - s = append(s, kdfLabel...) + s = append(s, kdfLabelX25519...) return s } diff --git a/keygen_test.go b/x25519_test.go similarity index 99% rename from keygen_test.go rename to x25519_test.go index a221f08..01f7344 100644 --- a/keygen_test.go +++ b/x25519_test.go @@ -168,7 +168,7 @@ func FuzzSaltWithLabel(f *testing.F) { } f.Fuzz(func(t *testing.T, salt []byte) { swl := saltWithLabel(salt) - if !slices.Equal(swl, append(salt, []byte(kdfLabel)...)) { + if !slices.Equal(swl, append(salt, []byte(kdfLabelX25519)...)) { t.Fatalf("saltWithLabel has invalid value: %v", swl) } })