Skip to content

Commit cbda857

Browse files
feat(zk): add ElGamal & AES key derivation
1 parent 75c68a3 commit cbda857

10 files changed

Lines changed: 805 additions & 0 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/gagliardetto/solana-go
33
go 1.24.0
44

55
require (
6+
filippo.io/edwards25519 v1.2.0
67
github.com/AlekSi/pointer v1.2.0
78
github.com/buger/jsonparser v1.1.2
89
github.com/davecgh/go-spew v1.1.1
@@ -28,6 +29,7 @@ require (
2829
github.com/spf13/viper v1.21.0
2930
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e
3031
github.com/stretchr/testify v1.11.1
32+
github.com/tyler-smith/go-bip39 v1.1.0
3133
go.mongodb.org/mongo-driver/v2 v2.5.0
3234
go.uber.org/ratelimit v0.3.1
3335
go.uber.org/zap v1.27.0

go.sum

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
44
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
55
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
66
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
7+
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
8+
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
79
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
810
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
911
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -151,6 +153,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
151153
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
152154
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
153155
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
156+
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
157+
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
154158
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
155159
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
156160
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
@@ -189,6 +193,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
189193
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
190194
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
191195
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
196+
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
192197
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
193198
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
194199
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package zkencryption
2+
3+
import (
4+
"crypto/sha3"
5+
"fmt"
6+
7+
"github.com/gagliardetto/solana-go"
8+
"github.com/tyler-smith/go-bip39"
9+
)
10+
11+
// AeKeyLen is the byte length of an authenticated-encryption key (AES-128-GCM-SIV).
12+
const AeKeyLen = 16
13+
14+
// aeSigningDomain is the domain-separation prefix prepended to the public
15+
// seed before signing. It must match b"AeKey" in solana-zk-sdk.
16+
const aeSigningDomain = "AeKey"
17+
18+
// minAeSeedLen / maxAeSeedLen mirror the bounds enforced in solana-zk-sdk's
19+
// SeedDerivable::from_seed implementation for AeKey.
20+
const (
21+
minAeSeedLen = AeKeyLen
22+
maxAeSeedLen = 65535
23+
)
24+
25+
// AeKey is a 128-bit authenticated-encryption key used by the Token-2022
26+
// confidential-transfer extension to encrypt u64 amounts under AES-128-GCM-SIV.
27+
type AeKey [AeKeyLen]byte
28+
29+
// AeKeyFromSeed derives an AeKey from an entropy seed by hashing the seed with
30+
// SHA3-512 and taking the first 16 bytes, matching SeedDerivable::from_seed in
31+
// solana-zk-sdk.
32+
func AeKeyFromSeed(seed []byte) (AeKey, error) {
33+
if len(seed) < minAeSeedLen {
34+
return AeKey{}, ErrSeedTooShort
35+
}
36+
if len(seed) > maxAeSeedLen {
37+
return AeKey{}, ErrSeedTooLong
38+
}
39+
h := sha3.Sum512(seed)
40+
var out AeKey
41+
copy(out[:], h[:AeKeyLen])
42+
return out, nil
43+
}
44+
45+
// AeKeyFromSignature derives an AeKey from an ed25519 signature by using
46+
// SHA3-512(signature) as the seed. Mirrors AeKey::seed_from_signature +
47+
// from_seed in solana-zk-sdk. No default-signature check is performed here;
48+
// use AeKeyFromSigner if the signature originates from a local signer.
49+
func AeKeyFromSignature(sig solana.Signature) (AeKey, error) {
50+
h := sha3.Sum512(sig[:])
51+
return AeKeyFromSeed(h[:])
52+
}
53+
54+
// AeKeyFromSigner deterministically derives an AeKey from a Solana signer and
55+
// a public seed. The signer signs b"AeKey" || publicSeed; the signature is
56+
// then hashed with SHA3-512 and the result fed into AeKeyFromSeed. An
57+
// all-zero (default) signature is rejected, matching the Rust implementation.
58+
func AeKeyFromSigner(signer Signer, publicSeed []byte) (AeKey, error) {
59+
msg := make([]byte, 0, len(aeSigningDomain)+len(publicSeed))
60+
msg = append(msg, aeSigningDomain...)
61+
msg = append(msg, publicSeed...)
62+
63+
sig, err := signer.Sign(msg)
64+
if err != nil {
65+
return AeKey{}, fmt.Errorf("zkencryption: sign AeKey public seed: %w", err)
66+
}
67+
if sig == (solana.Signature{}) {
68+
return AeKey{}, ErrDefaultSignature
69+
}
70+
return AeKeyFromSignature(sig)
71+
}
72+
73+
// AeKeyFromSeedPhraseAndPassphrase derives an AeKey from a BIP39 mnemonic and
74+
// an optional passphrase using the standard BIP39 PBKDF2-HMAC-SHA512 seed
75+
// derivation (2048 iterations, 64-byte output), matching
76+
// solana_seed_phrase::generate_seed_from_seed_phrase_and_passphrase. Solana
77+
// does not validate the mnemonic checksum at this layer, and neither do we.
78+
func AeKeyFromSeedPhraseAndPassphrase(mnemonic, passphrase string) (AeKey, error) {
79+
return AeKeyFromSeed(bip39.NewSeed(mnemonic, passphrase))
80+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Package zkencryption ports the deterministic key-derivation functions from
2+
// solana-zk-sdk (zk-sdk/src/encryption) to Go. It produces byte-for-byte
3+
// identical ElGamal secret keys and authenticated-encryption (AeKey) keys to
4+
// the Rust and JS/WASM reference implementations, so the same signer and
5+
// public seed derive the same key material across all three SDKs.
6+
//
7+
// Scope: key derivation only. Encryption, decryption, Pedersen commitments,
8+
// and zero-knowledge proof generation are not in this package; callers that
9+
// need a full confidential-transfer flow must still produce proofs via an
10+
// external source (Rust solana-zk-sdk or JS @solana/zk-sdk WASM).
11+
//
12+
// Reference: https://github.com/solana-program/zk-elgamal-proof
13+
package zkencryption
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package zkencryption
2+
3+
import (
4+
"crypto/sha3"
5+
"fmt"
6+
7+
"filippo.io/edwards25519"
8+
"github.com/gagliardetto/solana-go"
9+
"github.com/tyler-smith/go-bip39"
10+
)
11+
12+
// ElGamalSecretKeyLen is the canonical length of an ElGamal secret scalar
13+
// encoded in little-endian form (matches curve25519-dalek Scalar::as_bytes).
14+
const ElGamalSecretKeyLen = 32
15+
16+
// elGamalSigningDomain is the domain-separation prefix prepended to the
17+
// public seed before signing. It must match b"ElGamalSecretKey" in
18+
// solana-zk-sdk.
19+
const elGamalSigningDomain = "ElGamalSecretKey"
20+
21+
// minElGamalSeedLen / maxElGamalSeedLen mirror the bounds enforced in
22+
// solana-zk-sdk's ElGamalSecretKey::from_seed implementation.
23+
const (
24+
minElGamalSeedLen = ElGamalSecretKeyLen
25+
maxElGamalSeedLen = 65535
26+
)
27+
28+
// ElGamalSecretKey is a canonical little-endian encoding of a Ristretto/Ed25519
29+
// scalar mod ell. It is the Token-2022 confidential-transfer ElGamal private
30+
// key; byte-for-byte equivalent to ElGamalSecretKey::as_bytes in solana-zk-sdk.
31+
type ElGamalSecretKey [ElGamalSecretKeyLen]byte
32+
33+
// ElGamalSecretKeyFromSeed derives an ElGamal secret key from an entropy seed
34+
// by computing Scalar::from_bytes_mod_order_wide(SHA3-512(seed)), matching
35+
// curve25519-dalek's Scalar::hash_from_bytes::<Sha3_512>.
36+
func ElGamalSecretKeyFromSeed(seed []byte) (ElGamalSecretKey, error) {
37+
if len(seed) < minElGamalSeedLen {
38+
return ElGamalSecretKey{}, ErrSeedTooShort
39+
}
40+
if len(seed) > maxElGamalSeedLen {
41+
return ElGamalSecretKey{}, ErrSeedTooLong
42+
}
43+
44+
h := sha3.Sum512(seed)
45+
// SetUniformBytes only errors on wrong input length; Sum512 always
46+
// returns 64 bytes, so this branch is unreachable in practice but kept
47+
// to avoid an implicit panic if the upstream contract ever changes.
48+
s, err := edwards25519.NewScalar().SetUniformBytes(h[:])
49+
if err != nil {
50+
return ElGamalSecretKey{}, ErrInvalidScalarEncoding
51+
}
52+
53+
var out ElGamalSecretKey
54+
copy(out[:], s.Bytes())
55+
return out, nil
56+
}
57+
58+
// ElGamalSecretKeyFromSignature derives an ElGamal secret key from an ed25519
59+
// signature by using SHA3-512(signature) as the seed. Mirrors
60+
// ElGamalSecretKey::seed_from_signature + from_seed in solana-zk-sdk.
61+
func ElGamalSecretKeyFromSignature(sig solana.Signature) (ElGamalSecretKey, error) {
62+
h := sha3.Sum512(sig[:])
63+
return ElGamalSecretKeyFromSeed(h[:])
64+
}
65+
66+
// ElGamalSecretKeyFromSigner deterministically derives an ElGamal secret key
67+
// from a Solana signer and a public seed. The signer signs
68+
// b"ElGamalSecretKey" || publicSeed; the signature is hashed with SHA3-512
69+
// and fed into ElGamalSecretKeyFromSeed. An all-zero (default) signature is
70+
// rejected to match the Rust implementation.
71+
func ElGamalSecretKeyFromSigner(signer Signer, publicSeed []byte) (ElGamalSecretKey, error) {
72+
msg := make([]byte, 0, len(elGamalSigningDomain)+len(publicSeed))
73+
msg = append(msg, elGamalSigningDomain...)
74+
msg = append(msg, publicSeed...)
75+
76+
sig, err := signer.Sign(msg)
77+
if err != nil {
78+
return ElGamalSecretKey{}, fmt.Errorf("zkencryption: sign ElGamalSecretKey public seed: %w", err)
79+
}
80+
if sig == (solana.Signature{}) {
81+
return ElGamalSecretKey{}, ErrDefaultSignature
82+
}
83+
return ElGamalSecretKeyFromSignature(sig)
84+
}
85+
86+
// ElGamalSecretKeyFromSeedPhraseAndPassphrase derives an ElGamal secret key
87+
// from a BIP39 mnemonic and an optional passphrase, matching
88+
// solana_seed_phrase's PBKDF2-HMAC-SHA512 derivation. Solana does not
89+
// validate the mnemonic checksum at this layer, and neither do we.
90+
func ElGamalSecretKeyFromSeedPhraseAndPassphrase(mnemonic, passphrase string) (ElGamalSecretKey, error) {
91+
return ElGamalSecretKeyFromSeed(bip39.NewSeed(mnemonic, passphrase))
92+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package zkencryption
2+
3+
import "errors"
4+
5+
var (
6+
ErrSeedTooShort = errors.New("zkencryption: seed is too short")
7+
ErrSeedTooLong = errors.New("zkencryption: seed is too long")
8+
ErrDefaultSignature = errors.New("zkencryption: refusing to derive key from default (all-zero) signature")
9+
ErrInvalidScalarEncoding = errors.New("zkencryption: scalar wide-reduction failed")
10+
)

0 commit comments

Comments
 (0)