Skip to content

Commit 9fcbf0c

Browse files
feat(zk): add ElGamal & AES key derivation (#413)
* feat(zk): add ElGamal & AES key derivation * docs: add example * test: add elgamal too long case
1 parent b857519 commit 9fcbf0c

11 files changed

Lines changed: 876 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 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

go.sum

Lines changed: 2 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=
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+
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2021 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Command deriveKeys shows how to deterministically derive the two key
16+
// materials used by the Token-2022 confidential-transfer extension from a
17+
// Solana signer: the ElGamal secret key and the AES (AeKey) key.
18+
//
19+
// This is the primary derivation path used by wallets: the same signer and
20+
// public seed always derive the same keys, so a user can recover their
21+
// confidential-transfer keys from their wallet alone, with nothing stored
22+
// on-chain or off-chain. The keys produced here are byte-for-byte identical
23+
// to those from the Rust solana-zk-sdk and the JS/WASM @solana/zk-sdk, given
24+
// the same signer and seed.
25+
package main
26+
27+
import (
28+
"encoding/hex"
29+
"fmt"
30+
31+
"github.com/gagliardetto/solana-go"
32+
"github.com/gagliardetto/solana-go/programs/token-2022/zkencryption"
33+
)
34+
35+
func main() {
36+
// In a real application this is the user's wallet key (or a hardware
37+
// wallet / remote signer implementing zkencryption.Signer). solana.PrivateKey
38+
// already satisfies the Signer interface. We use a fixed seed here only so
39+
// the example output is reproducible.
40+
wallet := solana.NewWallet().PrivateKey
41+
42+
// The public seed scopes the derived keys. For confidential transfers this
43+
// is conventionally the token account (ATA) address whose balance the keys
44+
// protect, so distinct accounts owned by the same wallet get distinct keys.
45+
tokenAccount := solana.NewWallet().PublicKey()
46+
publicSeed := tokenAccount.Bytes()
47+
48+
elgamal, err := zkencryption.ElGamalSecretKeyFromSigner(wallet, publicSeed)
49+
if err != nil {
50+
panic(err)
51+
}
52+
53+
aeKey, err := zkencryption.AeKeyFromSigner(wallet, publicSeed)
54+
if err != nil {
55+
panic(err)
56+
}
57+
58+
fmt.Println("wallet: ", wallet.PublicKey())
59+
fmt.Println("token account: ", tokenAccount)
60+
fmt.Println("ElGamal secret key:", hex.EncodeToString(elgamal[:]))
61+
fmt.Println("AeKey: ", hex.EncodeToString(aeKey[:]))
62+
63+
// Derivation is deterministic: signer + public seed always yield the same
64+
// keys, which is what lets a wallet recover them on demand.
65+
again, err := zkencryption.ElGamalSecretKeyFromSigner(wallet, publicSeed)
66+
if err != nil {
67+
panic(err)
68+
}
69+
fmt.Println("deterministic: ", elgamal == again)
70+
}

0 commit comments

Comments
 (0)