Skip to content

Commit 5a84fab

Browse files
committed
feat(go-sdk): add timestamped env key signature verification
1 parent 0716dab commit 5a84fab

File tree

2 files changed

+158
-40
lines changed

2 files changed

+158
-40
lines changed

sdk/go/dstack/verify_signature.go

Lines changed: 95 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,90 +6,145 @@ package dstack
66

77
import (
88
"bytes"
9+
"encoding/binary"
910
"encoding/hex"
11+
"fmt"
1012
"strings"
13+
"time"
1114

1215
"github.com/ethereum/go-ethereum/crypto"
1316
"golang.org/x/crypto/sha3"
1417
)
1518

16-
// VerifyEnvEncryptPublicKey verifies the signature of a public key.
17-
//
18-
// Parameters:
19-
// - publicKey: The public key bytes to verify (32 bytes)
20-
// - signature: The signature bytes (65 bytes)
21-
// - appID: The application ID
22-
//
23-
// Returns the compressed public key if valid, nil otherwise
24-
func VerifyEnvEncryptPublicKey(publicKey []byte, signature []byte, appID string) ([]byte, error) {
25-
if len(signature) != 65 {
26-
return nil, nil
19+
const (
20+
defaultVerifyMaxAgeSeconds uint64 = 300
21+
defaultVerifyFutureSkewSeconds uint64 = 60
22+
)
23+
24+
// VerifyEnvEncryptPublicKeyOptions configures timestamp validation for signature verification.
25+
type VerifyEnvEncryptPublicKeyOptions struct {
26+
MaxAgeSeconds uint64
27+
FutureSkewSeconds uint64
28+
}
29+
30+
func normalizeVerifyOptions(opts *VerifyEnvEncryptPublicKeyOptions) (maxAgeSeconds uint64, futureSkewSeconds uint64) {
31+
maxAgeSeconds = defaultVerifyMaxAgeSeconds
32+
futureSkewSeconds = defaultVerifyFutureSkewSeconds
33+
if opts == nil {
34+
return
35+
}
36+
if opts.MaxAgeSeconds > 0 {
37+
maxAgeSeconds = opts.MaxAgeSeconds
2738
}
39+
if opts.FutureSkewSeconds > 0 {
40+
futureSkewSeconds = opts.FutureSkewSeconds
41+
}
42+
return
43+
}
2844

29-
// Create the message to verify
45+
func buildVerifyMessage(publicKey []byte, appID string) ([]byte, error) {
3046
prefix := []byte("dstack-env-encrypt-pubkey")
31-
32-
// Remove 0x prefix if present
47+
3348
cleanAppID := appID
3449
if strings.HasPrefix(appID, "0x") {
3550
cleanAppID = appID[2:]
3651
}
37-
52+
3853
appIDBytes, err := hex.DecodeString(cleanAppID)
3954
if err != nil {
40-
return nil, nil
55+
return nil, err
4156
}
42-
57+
4358
separator := []byte(":")
44-
45-
// Construct message: prefix + ":" + app_id + public_key
46-
message := bytes.Join([][]byte{prefix, separator, appIDBytes, publicKey}, nil)
47-
48-
// Hash the message with Keccak-256
59+
return bytes.Join([][]byte{prefix, separator, appIDBytes, publicKey}, nil), nil
60+
}
61+
62+
func recoverCompressedPublicKey(message []byte, signature []byte) ([]byte, error) {
63+
if len(signature) != 65 {
64+
return nil, nil
65+
}
66+
4967
hasher := sha3.NewLegacyKeccak256()
5068
hasher.Write(message)
5169
messageHash := hasher.Sum(nil)
52-
53-
// Extract r, s, v from signature (last byte is recovery id)
70+
5471
r := signature[0:32]
5572
s := signature[32:64]
5673
recovery := signature[64]
57-
58-
// Create signature in format expected by go-ethereum
74+
5975
sigBytes := make([]byte, 64)
6076
copy(sigBytes[0:32], r)
6177
copy(sigBytes[32:64], s)
62-
63-
// Recover the public key from the signature
78+
6479
recoveredPubKey, err := crypto.SigToPub(messageHash, append(sigBytes, recovery))
6580
if err != nil {
6681
return nil, nil
6782
}
68-
69-
// Return compressed public key
83+
7084
compressedPubKey := crypto.CompressPubkey(recoveredPubKey)
71-
72-
// Add 0x prefix
7385
result := make([]byte, len(compressedPubKey)+2)
7486
result[0] = '0'
7587
result[1] = 'x'
7688
copy(result[2:], []byte(hex.EncodeToString(compressedPubKey)))
77-
89+
7890
return result, nil
7991
}
8092

93+
// VerifyEnvEncryptPublicKey verifies the signature of a public key (legacy format without timestamp).
94+
func VerifyEnvEncryptPublicKey(publicKey []byte, signature []byte, appID string) ([]byte, error) {
95+
message, err := buildVerifyMessage(publicKey, appID)
96+
if err != nil {
97+
return nil, nil
98+
}
99+
return recoverCompressedPublicKey(message, signature)
100+
}
101+
102+
// VerifyEnvEncryptPublicKeyWithTimestamp verifies a public-key signature with timestamp freshness checks.
103+
//
104+
// Message format:
105+
//
106+
// prefix + ":" + app_id + timestamp_be_u64 + public_key
107+
func VerifyEnvEncryptPublicKeyWithTimestamp(
108+
publicKey []byte,
109+
signature []byte,
110+
appID string,
111+
timestamp uint64,
112+
opts *VerifyEnvEncryptPublicKeyOptions,
113+
) ([]byte, error) {
114+
if len(signature) != 65 {
115+
return nil, nil
116+
}
117+
118+
maxAgeSeconds, futureSkewSeconds := normalizeVerifyOptions(opts)
119+
now := uint64(time.Now().Unix())
120+
if timestamp > now {
121+
if timestamp-now > futureSkewSeconds {
122+
return nil, fmt.Errorf("timestamp is too far in the future")
123+
}
124+
} else if now-timestamp > maxAgeSeconds {
125+
return nil, fmt.Errorf("timestamp is too old: %ds > %ds", now-timestamp, maxAgeSeconds)
126+
}
127+
128+
baseMessage, err := buildVerifyMessage(publicKey, appID)
129+
if err != nil {
130+
return nil, nil
131+
}
132+
133+
timestampBytes := make([]byte, 8)
134+
binary.BigEndian.PutUint64(timestampBytes, timestamp)
135+
message := append(append([]byte{}, baseMessage[:len(baseMessage)-len(publicKey)]...), timestampBytes...)
136+
message = append(message, publicKey...)
137+
138+
return recoverCompressedPublicKey(message, signature)
139+
}
140+
81141
// VerifySignatureSimple is a simplified version for basic signature verification
82142
func VerifySignatureSimple(message []byte, signature []byte, expectedPubKey []byte) bool {
83143
if len(signature) != 65 {
84144
return false
85145
}
86-
87-
// Hash the message
146+
88147
hash := crypto.Keccak256Hash(message)
89-
90-
// Remove recovery ID for verification
91148
sig := signature[:64]
92-
93-
// Verify signature
94149
return crypto.VerifySignature(expectedPubKey, hash.Bytes(), sig)
95-
}
150+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// SPDX-FileCopyrightText: © 2025 Phala Network <dstack@phala.network>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package dstack_test
6+
7+
import (
8+
"testing"
9+
"time"
10+
11+
"github.com/Dstack-TEE/dstack/sdk/go/dstack"
12+
)
13+
14+
func TestVerifyEnvEncryptPublicKeyWithTimestampTooOld(t *testing.T) {
15+
pub := make([]byte, 32)
16+
sig := make([]byte, 65)
17+
now := uint64(time.Now().Unix())
18+
19+
_, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp(
20+
pub,
21+
sig,
22+
"0000000000000000000000000000000000000000",
23+
now-301,
24+
nil,
25+
)
26+
if err == nil {
27+
t.Fatal("expected stale timestamp error")
28+
}
29+
}
30+
31+
func TestVerifyEnvEncryptPublicKeyWithTimestampTooFuture(t *testing.T) {
32+
pub := make([]byte, 32)
33+
sig := make([]byte, 65)
34+
now := uint64(time.Now().Unix())
35+
36+
_, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp(
37+
pub,
38+
sig,
39+
"0000000000000000000000000000000000000000",
40+
now+61,
41+
nil,
42+
)
43+
if err == nil {
44+
t.Fatal("expected future timestamp error")
45+
}
46+
}
47+
48+
func TestVerifyEnvEncryptPublicKeyWithTimestampCustomMaxAge(t *testing.T) {
49+
pub := make([]byte, 32)
50+
sig := make([]byte, 65)
51+
now := uint64(time.Now().Unix())
52+
53+
_, err := dstack.VerifyEnvEncryptPublicKeyWithTimestamp(
54+
pub,
55+
sig,
56+
"0000000000000000000000000000000000000000",
57+
now-400,
58+
&dstack.VerifyEnvEncryptPublicKeyOptions{MaxAgeSeconds: 500},
59+
)
60+
if err != nil {
61+
t.Fatalf("expected no timestamp error with custom max age, got: %v", err)
62+
}
63+
}

0 commit comments

Comments
 (0)