Skip to content

Commit 7015419

Browse files
authored
fix(zkencryption)!: align key derivation with solana-conf-bal/v1 (#485)
Switch confidential-balances key derivation to the solana-conf-bal/v1 HKDF-SHA512 scheme, matching derive_confidential_keys_from_ikm and derive_confidential_keys_from_signature in solana-zk-sdk. - enforce the Rust seed bounds per key: AE minimum 16, ElGamal minimum 32 - reject all-zero (default) signatures in the from-signature paths - add DeriveConfidentialKeys and DeriveConfidentialKeysFromSignature so a single signature from a non-deterministic signer derives both keys - retain the pre-v1 SHA3-512 scheme as deprecated *Legacy functions so existing keys stay reproducible - scrub expanded secret buffers (clear) after use - add the canonical Rust derivation vector and signer-vs-signature tests BREAKING CHANGE: derived keys change from the SHA3-512 scheme. Reproduce pre-migration keys with the deprecated *Legacy functions.
1 parent 547b8e9 commit 7015419

6 files changed

Lines changed: 442 additions & 106 deletions

File tree

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package zkencryption
22

33
import (
4-
"crypto/sha3"
4+
"crypto/hkdf"
5+
"crypto/sha512"
56
"fmt"
67

78
"github.com/gagliardetto/solana-go"
@@ -11,70 +12,76 @@ import (
1112
// AeKeyLen is the byte length of an authenticated-encryption key (AES-128-GCM-SIV).
1213
const AeKeyLen = 16
1314

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"
15+
// aeInfo is the HKDF info string that scopes the AE key expansion, matching
16+
// AE_HKDF_INFO in solana-zk-sdk.
17+
const aeInfo = "ae"
1718

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-
)
19+
// aeMinSeedLen is the minimum seed length accepted by the AE derivation. It
20+
// mirrors MINIMUM_SEED_LEN in solana-zk-sdk's derive_confidential_keys_from_ikm
21+
// (AE_KEY_LEN = 16), which is independent from the ElGamal minimum of 32.
22+
const aeMinSeedLen = AeKeyLen
2423

2524
// AeKey is a 128-bit authenticated-encryption key used by the Token-2022
2625
// confidential-transfer extension to encrypt u64 amounts under AES-128-GCM-SIV.
2726
type AeKey [AeKeyLen]byte
2827

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 {
28+
// deriveAeKey implements the solana-conf-bal/v1 derivation:
29+
// HKDF-SHA512(salt=SigningDomain, ikm).expand(info="ae", 16), matching
30+
// derive_confidential_keys_from_ikm in solana-zk-sdk.
31+
func deriveAeKey(ikm []byte) (AeKey, error) {
32+
if len(ikm) < aeMinSeedLen {
3433
return AeKey{}, ErrSeedTooShort
3534
}
36-
if len(seed) > maxAeSeedLen {
35+
if len(ikm) > maxSeedLen {
3736
return AeKey{}, ErrSeedTooLong
3837
}
39-
h := sha3.Sum512(seed)
38+
39+
key, err := hkdf.Key(sha512.New, ikm, []byte(SigningDomain), aeInfo, AeKeyLen)
40+
if err != nil {
41+
return AeKey{}, fmt.Errorf("zkencryption: HKDF expand ae: %w", err)
42+
}
43+
4044
var out AeKey
41-
copy(out[:], h[:AeKeyLen])
45+
copy(out[:], key)
46+
// key holds expanded secret material; scrub it before it leaves scope.
47+
clear(key)
4248
return out, nil
4349
}
4450

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.
51+
// AeKeyFromSeed derives an AeKey from raw input key material, matching
52+
// derive_confidential_keys_from_ikm in solana-zk-sdk.
53+
func AeKeyFromSeed(seed []byte) (AeKey, error) {
54+
return deriveAeKey(seed)
55+
}
56+
57+
// AeKeyFromSignature derives an AeKey from an ed25519 signature over
58+
// ConfidentialDerivationMessage. Mirrors derive_confidential_keys_from_signature
59+
// in solana-zk-sdk. An all-zero (default) signature is rejected, matching the
60+
// Rust implementation.
4961
func AeKeyFromSignature(sig solana.Signature) (AeKey, error) {
50-
h := sha3.Sum512(sig[:])
51-
return AeKeyFromSeed(h[:])
62+
if sig == (solana.Signature{}) {
63+
return AeKey{}, ErrDefaultSignature
64+
}
65+
return deriveAeKey(sig[:])
5266
}
5367

5468
// 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.
69+
// a public seed. The signer signs b"solana-conf-bal/v1" || publicSeed (see
70+
// ConfidentialDerivationMessage); the signature is fed through the HKDF-SHA512
71+
// solana-conf-bal/v1 derivation. The all-zero signature rejection lives in
72+
// AeKeyFromSignature.
5873
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)
74+
sig, err := signer.Sign(ConfidentialDerivationMessage(publicSeed))
6475
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
76+
return AeKey{}, fmt.Errorf("zkencryption: sign confidential-balances public seed: %w", err)
6977
}
7078
return AeKeyFromSignature(sig)
7179
}
7280

7381
// AeKeyFromSeedPhraseAndPassphrase derives an AeKey from a BIP39 mnemonic and
7482
// 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.
83+
// derivation (2048 iterations, 64-byte output). The seed is used directly as
84+
// the HKDF input key material.
7885
func AeKeyFromSeedPhraseAndPassphrase(mnemonic, passphrase string) (AeKey, error) {
7986
return AeKeyFromSeed(bip39.NewSeed(mnemonic, passphrase))
8087
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package zkencryption
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/gagliardetto/solana-go"
7+
)
8+
9+
// DeriveConfidentialKeys derives both confidential-balances keys (AE and
10+
// ElGamal) from a single ed25519 signature over
11+
// ConfidentialDerivationMessage(publicSeed). The signer signs the message
12+
// exactly once and both keys are expanded from that one signature, so the two
13+
// keys always belong together and are reproducible even with non-deterministic
14+
// signers (hardware wallets, hedged ed25519). Mirrors derive_confidential_keys
15+
// in solana-zk-sdk.
16+
func DeriveConfidentialKeys(signer Signer, publicSeed []byte) (ElGamalSecretKey, AeKey, error) {
17+
sig, err := signer.Sign(ConfidentialDerivationMessage(publicSeed))
18+
if err != nil {
19+
return ElGamalSecretKey{}, AeKey{}, fmt.Errorf("zkencryption: sign confidential-balances public seed: %w", err)
20+
}
21+
return DeriveConfidentialKeysFromSignature(sig)
22+
}
23+
24+
// DeriveConfidentialKeysFromSignature derives both confidential-balances keys
25+
// (AE and ElGamal) from an ed25519 signature over
26+
// ConfidentialDerivationMessage. Mirrors derive_confidential_keys_from_signature
27+
// in solana-zk-sdk. An all-zero (default) signature is rejected, matching the
28+
// Rust implementation.
29+
func DeriveConfidentialKeysFromSignature(sig solana.Signature) (ElGamalSecretKey, AeKey, error) {
30+
if sig == (solana.Signature{}) {
31+
return ElGamalSecretKey{}, AeKey{}, ErrDefaultSignature
32+
}
33+
ae, err := deriveAeKey(sig[:])
34+
if err != nil {
35+
return ElGamalSecretKey{}, AeKey{}, err
36+
}
37+
el, err := deriveElGamalSecretKey(sig[:])
38+
if err != nil {
39+
return ElGamalSecretKey{}, AeKey{}, err
40+
}
41+
return el, ae, nil
42+
}
Lines changed: 67 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package zkencryption
22

33
import (
4-
"crypto/sha3"
4+
"crypto/hkdf"
5+
"crypto/sha512"
56
"fmt"
67

78
"filippo.io/edwards25519"
@@ -13,39 +14,62 @@ import (
1314
// encoded in little-endian form (matches curve25519-dalek Scalar::as_bytes).
1415
const ElGamalSecretKeyLen = 32
1516

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"
17+
// SigningDomain is the domain-separation prefix for confidential-balances key
18+
// derivation. It must match HKDF_SALT (b"solana-conf-bal/v1") in solana-zk-sdk
19+
// (derive_confidential_keys_from_ikm): it is both the message prefix the
20+
// signer signs and the HKDF salt.
21+
const SigningDomain = "solana-conf-bal/v1"
2022

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-
)
23+
// elgamalInfo is the HKDF info string that scopes the ElGamal expansion,
24+
// matching ELGAMAL_HKDF_INFO in solana-zk-sdk.
25+
const elgamalInfo = "elgamal"
26+
27+
// elgamalMinSeedLen is the minimum seed length accepted by the ElGamal
28+
// derivation. It mirrors MINIMUM_SEED_LEN in solana-zk-sdk's
29+
// derive_confidential_keys_from_ikm (32), which is independent from the AE
30+
// minimum of 16.
31+
const elgamalMinSeedLen = ElGamalSecretKeyLen
32+
33+
// maxSeedLen mirrors the maximum bound enforced by solana-zk-sdk's
34+
// derive_confidential_keys_from_ikm (MAXIMUM_IKM_LEN). It applies to both the
35+
// AE and ElGamal derivations.
36+
const maxSeedLen = 65535
2737

2838
// ElGamalSecretKey is a canonical little-endian encoding of a Ristretto/Ed25519
2939
// scalar mod ell. It is the Token-2022 confidential-transfer ElGamal private
3040
// key; byte-for-byte equivalent to ElGamalSecretKey::as_bytes in solana-zk-sdk.
3141
type ElGamalSecretKey [ElGamalSecretKeyLen]byte
3242

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 {
43+
// ConfidentialDerivationMessage returns the canonical confidential-balances
44+
// derivation message, b"solana-conf-bal/v1" || publicSeed. Mirrors
45+
// confidential_derivation_message in solana-zk-sdk; this is the exact message
46+
// a Signer must sign to derive confidential-balances keys.
47+
func ConfidentialDerivationMessage(publicSeed []byte) []byte {
48+
msg := make([]byte, 0, len(SigningDomain)+len(publicSeed))
49+
msg = append(msg, SigningDomain...)
50+
msg = append(msg, publicSeed...)
51+
return msg
52+
}
53+
54+
// deriveElGamalSecretKey implements the solana-conf-bal/v1 derivation:
55+
// HKDF-SHA512(salt=SigningDomain, ikm).expand(info="elgamal", 64) reduced via
56+
// Scalar::from_bytes_mod_order_wide, matching derive_confidential_keys_from_ikm.
57+
func deriveElGamalSecretKey(ikm []byte) (ElGamalSecretKey, error) {
58+
if len(ikm) < elgamalMinSeedLen {
3859
return ElGamalSecretKey{}, ErrSeedTooShort
3960
}
40-
if len(seed) > maxElGamalSeedLen {
61+
if len(ikm) > maxSeedLen {
4162
return ElGamalSecretKey{}, ErrSeedTooLong
4263
}
4364

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[:])
65+
wide, err := hkdf.Key(sha512.New, ikm, []byte(SigningDomain), elgamalInfo, 64)
66+
if err != nil {
67+
return ElGamalSecretKey{}, fmt.Errorf("zkencryption: HKDF expand elgamal: %w", err)
68+
}
69+
// SetUniformBytes performs Scalar::from_bytes_mod_order_wide on 64 bytes.
70+
s, err := edwards25519.NewScalar().SetUniformBytes(wide)
71+
// wide holds expanded secret material; scrub it before it leaves scope.
72+
clear(wide)
4973
if err != nil {
5074
return ElGamalSecretKey{}, ErrInvalidScalarEncoding
5175
}
@@ -55,38 +79,40 @@ func ElGamalSecretKeyFromSeed(seed []byte) (ElGamalSecretKey, error) {
5579
return out, nil
5680
}
5781

82+
// ElGamalSecretKeyFromSeed derives an ElGamal secret key from raw input key
83+
// material, matching derive_confidential_keys_from_ikm in solana-zk-sdk.
84+
func ElGamalSecretKeyFromSeed(seed []byte) (ElGamalSecretKey, error) {
85+
return deriveElGamalSecretKey(seed)
86+
}
87+
5888
// 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.
89+
// signature over ConfidentialDerivationMessage. Mirrors
90+
// derive_confidential_keys_from_signature in solana-zk-sdk. An all-zero
91+
// (default) signature is rejected, matching the Rust implementation.
6192
func ElGamalSecretKeyFromSignature(sig solana.Signature) (ElGamalSecretKey, error) {
62-
h := sha3.Sum512(sig[:])
63-
return ElGamalSecretKeyFromSeed(h[:])
93+
if sig == (solana.Signature{}) {
94+
return ElGamalSecretKey{}, ErrDefaultSignature
95+
}
96+
return deriveElGamalSecretKey(sig[:])
6497
}
6598

6699
// ElGamalSecretKeyFromSigner deterministically derives an ElGamal secret key
67100
// 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.
101+
// b"solana-conf-bal/v1" || publicSeed (see ConfidentialDerivationMessage); the
102+
// signature is fed through the HKDF-SHA512 solana-conf-bal/v1 derivation. The
103+
// all-zero signature rejection lives in ElGamalSecretKeyFromSignature.
71104
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)
105+
sig, err := signer.Sign(ConfidentialDerivationMessage(publicSeed))
77106
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
107+
return ElGamalSecretKey{}, fmt.Errorf("zkencryption: sign confidential-balances public seed: %w", err)
82108
}
83109
return ElGamalSecretKeyFromSignature(sig)
84110
}
85111

86112
// 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.
113+
// from a BIP39 mnemonic and an optional passphrase using the standard BIP39
114+
// PBKDF2-HMAC-SHA512 seed derivation (2048 iterations, 64-byte output). The
115+
// seed is used directly as the HKDF input key material.
90116
func ElGamalSecretKeyFromSeedPhraseAndPassphrase(mnemonic, passphrase string) (ElGamalSecretKey, error) {
91117
return ElGamalSecretKeyFromSeed(bip39.NewSeed(mnemonic, passphrase))
92118
}

0 commit comments

Comments
 (0)