feat(identity): add BIP-39 mnemonic support for identity key derivation
Kim committed
May 27, 2026 at 10:51 UTC
ffa4276dab162e9f39c2a29c0aa8b27caaf77bf0
10 files changed
+344
-60
docs/src/routes/configuration/+page.md
+6
-1
@@ -298,11 +298,16 @@ Stores the secp256k1 identity used to sign tunnel sessions and relay descriptors
298
| `address` | string | Derived EVM address used for SIWE and identity ownership |
299
| `public_key` | string | Compressed secp256k1 public key hex |
300
| `private_key` | string | secp256k1 private key hex; keep secret |
301
+| `mnemonic` | string | BIP-39 mnemonic used to derive the secp256k1 identity key; keep secret |
302
+| `derivation_path` | string | EVM derivation path for `mnemonic`; defaults to `m/44'/60'/0'/0/0` |
303
| `wireguard_public_key` | string | Relay-only WireGuard overlay public key when discovery is enabled |
304
| `wireguard_private_key` | string | Relay-only WireGuard overlay private key when discovery is enabled |
305
| `encrypted_client_hello_seed` | string | Relay-only HKDF salt for deriving the ECH HPKE private key; generated automatically when missing; keep secret |
306
305
-The same identity file or state directory can be reused across restarts to keep a stable address.
307
+When `mnemonic` is present, Portal derives the private key at `derivation_path`
308
+and preserves the mnemonic form when rewriting `identity.json`. The same
309
+identity file or state directory can be reused across restarts to keep a stable
310
+address.
311
312
### `admin_settings.json`
313
docs/src/routes/security-model/+page.md
+1
-1
@@ -62,7 +62,7 @@ Raw TCP and UDP port transports do not add tenant TLS. Use application-level enc
62
63
## Identity
64
65
-Registration uses a SIWE challenge signed by the SDK's secp256k1 identity key. The relay then issues a lease-scoped ES256K access token used by renew, unregister, reverse connect, and QUIC datagram authentication.
65
+Registration uses a SIWE challenge signed by the SDK's secp256k1 identity key. The key is loaded from `identity.json` either as a raw secp256k1 `private_key` or derived from a BIP-39 `mnemonic` and `derivation_path`. The relay then issues a lease-scoped ES256K access token used by renew, unregister, reverse connect, and QUIC datagram authentication.
66
67
Browser wallet login is a separate admin/status mechanism. It does not replace
68
the local tunnel identity used for lease registration.
docs/src/routes/siwe-authentication/+page.md
+4
@@ -19,6 +19,10 @@ from `identity.json`. During registration, the relay returns a SIWE challenge
19
with statement `Register a portal lease`; the tunnel signs it with the local
20
identity private key and receives a lease access token.
21
22
+`identity.json` can store the signing key directly as `private_key`, or store a
23
+BIP-39 `mnemonic` with `derivation_path` and let Portal derive the key at load
24
+time.
25
+
26
This flow is automatic. It does not require a browser wallet.
27
28
```bash
docs/src/routes/wallet-and-ens/+page.md
+5
-3
@@ -12,8 +12,8 @@ related, but they do not all mean "connect a browser wallet".
12
13
| Surface | Key material | Purpose |
14
|---------|--------------|---------|
15
-| Tunnel identity | Local `identity.json` secp256k1 private key | Signs SIWE lease registration challenges |
16
-| Relay identity | Relay `IDENTITY_PATH/identity.json` secp256k1 private key | Signs relay descriptors, admin default wallet, lease access tokens, and ENS base-domain address |
15
+| Tunnel identity | Local `identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs SIWE lease registration challenges |
16
+| Relay identity | Relay `IDENTITY_PATH/identity.json` secp256k1 private key, or BIP-39 mnemonic plus derivation path | Signs relay descriptors, admin default wallet, lease access tokens, and ENS base-domain address |
17
| Relay admin wallet | Browser wallet address allowlist | Signs in to `/admin` with a SIWE wallet session |
18
| Agent wallet | Optional browser wallet allowlist | Reads loopback agent status through `/v1/agent/status` |
19
| ENS gasless DNS | DNSSEC plus `ENS1 ...` TXT records | Lets ENS-aware clients resolve the relay domain and lease hostnames to Portal identities |
@@ -32,7 +32,9 @@ Tunnel registration always uses a SIWE challenge internally:
32
signing access, and UDP backhaul authentication.
33
34
This does not require MetaMask or a user wallet. It is accountless identity
35
-proof based on the local tunnel key.
35
+proof based on the local tunnel key. `identity.json` may store a raw
36
+`private_key`, or a BIP-39 `mnemonic` with `derivation_path` such as
37
+`m/44'/60'/0'/0/0`.
38
39
There is no `--auth siwe` flag. The current CLI command is:
40
go.mod
+1
@@ -30,6 +30,7 @@ require (
30
github.com/quic-go/quic-go v0.59.0
31
github.com/rs/zerolog v1.34.0
32
github.com/spruceid/siwe-go v0.2.1
33
+ github.com/tyler-smith/go-bip39 v1.1.0
34
github.com/vultr/govultr/v3 v3.30.0
35
github.com/x402-foundation/x402/go v0.0.0-20260526081544-8cf020c5335e
36
golang.org/x/crypto v0.51.0
go.sum
+8
@@ -381,6 +381,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA
381
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
382
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
383
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
384
+github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
385
+github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
386
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
387
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
388
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -441,12 +443,15 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
443
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
444
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
445
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
446
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
447
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
448
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
449
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
450
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
451
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
452
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
453
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
454
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
455
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
456
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
457
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
@@ -454,6 +459,8 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
459
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
460
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
461
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
462
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
463
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
464
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
465
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
466
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -467,6 +474,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
474
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
475
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
476
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
477
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
478
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
479
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
480
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
portal/identity/authority.go
+2
@@ -36,6 +36,8 @@ func NewLocalAuthority(raw types.Identity) (LocalAuthority, error) {
36
func (a LocalAuthority) Identity() types.Identity {
37
identity := a.identity.Copy()
38
identity.PrivateKey = ""
39
+ identity.Mnemonic = ""
40
+ identity.DerivationPath = ""
41
identity.TokenSecret = ""
42
return identity
43
}
portal/identity/mnemonic.go
new
+212
@@ -0,0 +1,212 @@
1
+package identity
2
+
3
+import (
4
+ "crypto/hmac"
5
+ "crypto/sha512"
6
+ "encoding/binary"
7
+ "encoding/hex"
8
+ "errors"
9
+ "fmt"
10
+ "math/big"
11
+ "strconv"
12
+ "strings"
13
+
14
+ "github.com/decred/dcrd/dcrec/secp256k1/v4"
15
+ "github.com/tyler-smith/go-bip39"
16
+)
17
+
18
+const (
19
+ DefaultEVMIdentityDerivationPath = "m/44'/60'/0'/0/0"
20
+
21
+ bip32HardenedOffset = uint32(0x80000000)
22
+)
23
+
24
+type derivationPath []uint32
25
+
26
+var defaultEVMRootDerivationPath = derivationPath{
27
+ bip32HardenedOffset + 44,
28
+ bip32HardenedOffset + 60,
29
+ bip32HardenedOffset,
30
+ 0,
31
+}
32
+
33
+func deriveSecp256k1PrivateKeyFromMnemonic(rawMnemonic, rawDerivationPath string) (string, string, error) {
34
+ mnemonic := normalizeMnemonic(rawMnemonic)
35
+ if mnemonic == "" {
36
+ return "", "", errors.New("identity mnemonic is required")
37
+ }
38
+
39
+ derivationPath := strings.TrimSpace(rawDerivationPath)
40
+ if derivationPath == "" {
41
+ derivationPath = DefaultEVMIdentityDerivationPath
42
+ }
43
+ path, err := parseDerivationPath(derivationPath)
44
+ if err != nil {
45
+ return "", "", fmt.Errorf("parse identity derivation path: %w", err)
46
+ }
47
+
48
+ seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
49
+ if err != nil {
50
+ return "", "", fmt.Errorf("validate identity mnemonic: %w", err)
51
+ }
52
+ privateKey, err := deriveBIP32Secp256k1PrivateKey(seed, path)
53
+ if err != nil {
54
+ return "", "", err
55
+ }
56
+ return hex.EncodeToString(privateKey), path.String(), nil
57
+}
58
+
59
+func normalizeMnemonic(raw string) string {
60
+ return strings.ToLower(strings.Join(strings.Fields(raw), " "))
61
+}
62
+
63
+func parseDerivationPath(raw string) (derivationPath, error) {
64
+ components := strings.Split(raw, "/")
65
+ if len(components) == 0 {
66
+ return nil, errors.New("empty derivation path")
67
+ }
68
+
69
+ var path derivationPath
70
+ switch strings.TrimSpace(components[0]) {
71
+ case "":
72
+ return nil, errors.New("ambiguous path: use 'm/' prefix for absolute paths, or no leading '/' for relative ones")
73
+ case "m":
74
+ components = components[1:]
75
+ default:
76
+ path = append(path, defaultEVMRootDerivationPath...)
77
+ }
78
+ if len(components) == 0 {
79
+ return nil, errors.New("empty derivation path")
80
+ }
81
+
82
+ for _, component := range components {
83
+ component = strings.TrimSpace(component)
84
+ hardened := strings.HasSuffix(component, "'")
85
+ if hardened {
86
+ component = strings.TrimSpace(strings.TrimSuffix(component, "'"))
87
+ }
88
+ value, err := strconv.ParseUint(component, 0, 32)
89
+ if err != nil {
90
+ return nil, fmt.Errorf("invalid component: %s", component)
91
+ }
92
+ if hardened {
93
+ if value >= uint64(bip32HardenedOffset) {
94
+ return nil, fmt.Errorf("component %d out of allowed hardened range [0, %d]", value, bip32HardenedOffset-1)
95
+ }
96
+ value += uint64(bip32HardenedOffset)
97
+ }
98
+ path = append(path, uint32(value))
99
+ }
100
+ return path, nil
101
+}
102
+
103
+func (path derivationPath) String() string {
104
+ var builder strings.Builder
105
+ builder.WriteByte('m')
106
+ for _, component := range path {
107
+ builder.WriteByte('/')
108
+ hardened := component >= bip32HardenedOffset
109
+ if hardened {
110
+ component -= bip32HardenedOffset
111
+ }
112
+ builder.WriteString(strconv.FormatUint(uint64(component), 10))
113
+ if hardened {
114
+ builder.WriteByte('\'')
115
+ }
116
+ }
117
+ return builder.String()
118
+}
119
+
120
+func deriveBIP32Secp256k1PrivateKey(seed []byte, path derivationPath) ([]byte, error) {
121
+ if len(seed) == 0 {
122
+ return nil, errors.New("identity mnemonic seed is required")
123
+ }
124
+ if len(path) == 0 {
125
+ return nil, errors.New("identity derivation path is required")
126
+ }
127
+
128
+ mac := hmac.New(sha512.New, []byte("Bitcoin seed"))
129
+ _, _ = mac.Write(seed)
130
+ digest := mac.Sum(nil)
131
+
132
+ privateKey, err := normalizeBIP32PrivateKey(digest[:32])
133
+ if err != nil {
134
+ return nil, fmt.Errorf("derive identity master key: %w", err)
135
+ }
136
+ chainCode := append([]byte(nil), digest[32:]...)
137
+
138
+ for _, child := range path {
139
+ privateKey, chainCode, err = deriveBIP32Secp256k1ChildPrivateKey(privateKey, chainCode, child)
140
+ if err != nil {
141
+ return nil, fmt.Errorf("derive identity child key %d: %w", child, err)
142
+ }
143
+ }
144
+ return privateKey, nil
145
+}
146
+
147
+func deriveBIP32Secp256k1ChildPrivateKey(parentPrivateKey, parentChainCode []byte, child uint32) ([]byte, []byte, error) {
148
+ if len(parentChainCode) != 32 {
149
+ return nil, nil, errors.New("parent chain code must be 32 bytes")
150
+ }
151
+ parentKey, err := normalizeBIP32PrivateKey(parentPrivateKey)
152
+ if err != nil {
153
+ return nil, nil, fmt.Errorf("parent private key: %w", err)
154
+ }
155
+
156
+ data := make([]byte, 0, 37)
157
+ if child >= bip32HardenedOffset {
158
+ data = append(data, 0)
159
+ data = append(data, parentKey...)
160
+ } else {
161
+ data = append(data, secp256k1.PrivKeyFromBytes(parentKey).PubKey().SerializeCompressed()...)
162
+ }
163
+ var childBytes [4]byte
164
+ binary.BigEndian.PutUint32(childBytes[:], child)
165
+ data = append(data, childBytes[:]...)
166
+
167
+ mac := hmac.New(sha512.New, parentChainCode)
168
+ _, _ = mac.Write(data)
169
+ digest := mac.Sum(nil)
170
+
171
+ childKey, err := addBIP32PrivateKeys(digest[:32], parentKey)
172
+ if err != nil {
173
+ return nil, nil, err
174
+ }
175
+ return childKey, append([]byte(nil), digest[32:]...), nil
176
+}
177
+
178
+func addBIP32PrivateKeys(left, right []byte) ([]byte, error) {
179
+ order := secp256k1.Params().N
180
+ leftInt := new(big.Int).SetBytes(left)
181
+ if leftInt.Sign() == 0 || leftInt.Cmp(order) >= 0 {
182
+ return nil, errors.New("child key offset is outside the secp256k1 order")
183
+ }
184
+ rightInt := new(big.Int).SetBytes(right)
185
+ if rightInt.Sign() == 0 || rightInt.Cmp(order) >= 0 {
186
+ return nil, errors.New("parent private key is outside the secp256k1 order")
187
+ }
188
+
189
+ child := leftInt.Add(leftInt, rightInt)
190
+ child.Mod(child, order)
191
+ if child.Sign() == 0 {
192
+ return nil, errors.New("derived private key is zero")
193
+ }
194
+ return padded32(child), nil
195
+}
196
+
197
+func normalizeBIP32PrivateKey(raw []byte) ([]byte, error) {
198
+ key := new(big.Int).SetBytes(raw)
199
+ if key.Sign() == 0 {
200
+ return nil, errors.New("private key is zero")
201
+ }
202
+ if key.Cmp(secp256k1.Params().N) >= 0 {
203
+ return nil, errors.New("private key is outside the secp256k1 order")
204
+ }
205
+ return padded32(key), nil
206
+}
207
+
208
+func padded32(value *big.Int) []byte {
209
+ out := make([]byte, 32)
210
+ value.FillBytes(out)
211
+ return out
212
+}
portal/identity/store.go
+91
-45
@@ -143,8 +143,25 @@ func normalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
143
normalized.Address = strings.TrimSpace(normalized.Address)
144
normalized.PublicKey = strings.TrimSpace(normalized.PublicKey)
145
normalized.PrivateKey = strings.TrimSpace(normalized.PrivateKey)
146
+ normalized.Mnemonic = normalizeMnemonic(normalized.Mnemonic)
147
+ normalized.DerivationPath = strings.TrimSpace(normalized.DerivationPath)
148
normalized.TokenSecret = strings.TrimSpace(normalized.TokenSecret)
149
150
+ if normalized.Mnemonic != "" {
151
+ privateKey, derivationPath, err := deriveSecp256k1PrivateKeyFromMnemonic(normalized.Mnemonic, normalized.DerivationPath)
152
+ if err != nil {
153
+ return types.Identity{}, err
154
+ }
155
+ normalized.DerivationPath = derivationPath
156
+ if normalized.PrivateKey == "" {
157
+ normalized.PrivateKey = privateKey
158
+ } else if !strings.EqualFold(utils.TrimHexPrefix(normalized.PrivateKey), privateKey) {
159
+ return types.Identity{}, errors.New("identity private key does not match mnemonic")
160
+ }
161
+ } else if normalized.DerivationPath != "" {
162
+ return types.Identity{}, errors.New("identity derivation_path requires mnemonic")
163
+ }
164
+
165
switch {
166
case normalized.PrivateKey != "":
167
resolved, err := ResolveSecp256k1Identity(normalized.PrivateKey)
@@ -225,11 +242,13 @@ func normalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIden
242
}
243
244
type storedIdentity struct {
228
- Name string `json:"name,omitempty"`
229
- Address string `json:"address,omitempty"`
230
- PublicKey string `json:"public_key,omitempty"`
231
- PrivateKey string `json:"private_key,omitempty"`
232
- TokenSecret string `json:"token_secret,omitempty"`
245
+ Name string `json:"name,omitempty"`
246
+ Address string `json:"address,omitempty"`
247
+ PublicKey string `json:"public_key,omitempty"`
248
+ PrivateKey string `json:"private_key,omitempty"`
249
+ Mnemonic string `json:"mnemonic,omitempty"`
250
+ DerivationPath string `json:"derivation_path,omitempty"`
251
+ TokenSecret string `json:"token_secret,omitempty"`
252
}
253
254
type storedRelayIdentity struct {
@@ -239,6 +258,22 @@ type storedRelayIdentity struct {
258
EncryptedClientHelloSeed string `json:"encrypted_client_hello_seed,omitempty"`
259
}
260
261
+func storedIdentityFromIdentity(identity types.Identity) storedIdentity {
262
+ privateKey := identity.PrivateKey
263
+ if strings.TrimSpace(identity.Mnemonic) != "" {
264
+ privateKey = ""
265
+ }
266
+ return storedIdentity{
267
+ Name: identity.Name,
268
+ Address: identity.Address,
269
+ PublicKey: identity.PublicKey,
270
+ PrivateKey: privateKey,
271
+ Mnemonic: identity.Mnemonic,
272
+ DerivationPath: identity.DerivationPath,
273
+ TokenSecret: identity.TokenSecret,
274
+ }
275
+}
276
+
277
func saveIdentity(path string, identity types.Identity) error {
278
path = strings.TrimSpace(path)
279
if path == "" {
@@ -252,13 +287,7 @@ func saveIdentity(path string, identity types.Identity) error {
287
if err != nil {
288
return err
289
}
255
- if err := utils.WriteJSONFile(path, storedIdentity{
256
- Name: normalized.Name,
257
- Address: normalized.Address,
258
- PublicKey: normalized.PublicKey,
259
- PrivateKey: normalized.PrivateKey,
260
- TokenSecret: normalized.TokenSecret,
261
- }, 0o600); err != nil {
290
+ if err := utils.WriteJSONFile(path, storedIdentityFromIdentity(normalized), 0o600); err != nil {
291
return fmt.Errorf("write identity file: %w", err)
292
}
293
return nil
@@ -278,14 +307,9 @@ func saveRelayIdentity(path string, identity types.RelayIdentity) error {
307
return err
308
}
309
normalized.Identity = baseIdentity
310
+ storedBaseIdentity := storedIdentityFromIdentity(normalized.Identity)
311
if err := utils.WriteJSONFile(path, storedRelayIdentity{
282
- storedIdentity: storedIdentity{
283
- Name: normalized.Name,
284
- Address: normalized.Address,
285
- PublicKey: normalized.PublicKey,
286
- PrivateKey: normalized.PrivateKey,
287
- TokenSecret: normalized.TokenSecret,
288
- },
312
+ storedIdentity: storedBaseIdentity,
313
WireGuardPublicKey: normalized.WireGuardPublicKey,
314
WireGuardPrivateKey: normalized.WireGuardPrivateKey,
315
EncryptedClientHelloSeed: normalized.EncryptedClientHelloSeed,
@@ -305,11 +329,13 @@ func loadIdentity(path string) (types.Identity, error) {
329
return types.Identity{}, fmt.Errorf("read identity file: %w", err)
330
}
331
return normalizeStoredIdentity(types.Identity{
308
- Name: payload.Name,
309
- Address: payload.Address,
310
- PublicKey: payload.PublicKey,
311
- PrivateKey: payload.PrivateKey,
312
- TokenSecret: payload.TokenSecret,
332
+ Name: payload.Name,
333
+ Address: payload.Address,
334
+ PublicKey: payload.PublicKey,
335
+ PrivateKey: payload.PrivateKey,
336
+ Mnemonic: payload.Mnemonic,
337
+ DerivationPath: payload.DerivationPath,
338
+ TokenSecret: payload.TokenSecret,
339
})
340
}
341
@@ -324,11 +350,13 @@ func loadRelayIdentity(path string) (types.RelayIdentity, error) {
350
}
351
return normalizeStoredRelayIdentity(types.RelayIdentity{
352
Identity: types.Identity{
327
- Name: payload.Name,
328
- Address: payload.Address,
329
- PublicKey: payload.PublicKey,
330
- PrivateKey: payload.PrivateKey,
331
- TokenSecret: payload.TokenSecret,
353
+ Name: payload.Name,
354
+ Address: payload.Address,
355
+ PublicKey: payload.PublicKey,
356
+ PrivateKey: payload.PrivateKey,
357
+ Mnemonic: payload.Mnemonic,
358
+ DerivationPath: payload.DerivationPath,
359
+ TokenSecret: payload.TokenSecret,
360
},
361
WireGuardPublicKey: payload.WireGuardPublicKey,
362
WireGuardPrivateKey: payload.WireGuardPrivateKey,
@@ -347,11 +375,13 @@ func parseIdentityJSON(raw string) (types.Identity, error) {
375
return types.Identity{}, fmt.Errorf("decode identity json: %w", err)
376
}
377
return normalizeStoredIdentity(types.Identity{
350
- Name: payload.Name,
351
- Address: payload.Address,
352
- PublicKey: payload.PublicKey,
353
- PrivateKey: payload.PrivateKey,
354
- TokenSecret: payload.TokenSecret,
378
+ Name: payload.Name,
379
+ Address: payload.Address,
380
+ PublicKey: payload.PublicKey,
381
+ PrivateKey: payload.PrivateKey,
382
+ Mnemonic: payload.Mnemonic,
383
+ DerivationPath: payload.DerivationPath,
384
+ TokenSecret: payload.TokenSecret,
385
})
386
}
387
@@ -376,6 +406,12 @@ func loadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
406
if privateKey := strings.TrimSpace(identity.PrivateKey); privateKey != "" {
407
stored.PrivateKey = privateKey
408
}
409
+ if mnemonic := normalizeMnemonic(identity.Mnemonic); mnemonic != "" {
410
+ stored.Mnemonic = mnemonic
411
+ }
412
+ if derivationPath := strings.TrimSpace(identity.DerivationPath); derivationPath != "" {
413
+ stored.DerivationPath = derivationPath
414
+ }
415
if tokenSecret := strings.TrimSpace(identity.TokenSecret); tokenSecret != "" {
416
stored.TokenSecret = tokenSecret
417
}
@@ -395,17 +431,24 @@ func loadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
431
}
432
433
created := identity.Copy()
398
- generated, err := ResolveSecp256k1Identity(created.PrivateKey)
399
- if err != nil {
400
- return types.Identity{}, false, fmt.Errorf("generate identity: %w", err)
401
- }
402
- if strings.TrimSpace(created.Address) == "" {
403
- created.Address = generated.Address
404
- }
405
- if strings.TrimSpace(created.PublicKey) == "" {
406
- created.PublicKey = generated.PublicKey
434
+ if strings.TrimSpace(created.Mnemonic) != "" || strings.TrimSpace(created.DerivationPath) != "" {
435
+ created, err = normalizeStoredIdentity(created)
436
+ if err != nil {
437
+ return types.Identity{}, false, fmt.Errorf("resolve identity mnemonic: %w", err)
438
+ }
439
+ } else {
440
+ generated, err := ResolveSecp256k1Identity(created.PrivateKey)
441
+ if err != nil {
442
+ return types.Identity{}, false, fmt.Errorf("generate identity: %w", err)
443
+ }
444
+ if strings.TrimSpace(created.Address) == "" {
445
+ created.Address = generated.Address
446
+ }
447
+ if strings.TrimSpace(created.PublicKey) == "" {
448
+ created.PublicKey = generated.PublicKey
449
+ }
450
+ created.PrivateKey = generated.PrivateKey
451
}
408
- created.PrivateKey = generated.PrivateKey
452
if strings.TrimSpace(created.TokenSecret) == "" {
453
created, err = ensureTokenSecret(created)
454
if err != nil {
@@ -620,7 +663,10 @@ func resolveExposeName(name, target, identityPath, identityJSON string) (string,
663
}
664
665
func resolveLeaseIdentity(identity types.Identity) (types.Identity, error) {
623
- resolved := identity.Copy()
666
+ resolved, err := normalizeStoredIdentity(identity)
667
+ if err != nil {
668
+ return types.Identity{}, err
669
+ }
670
671
name, err := utils.NormalizeDNSLabel(resolved.Name)
672
if err != nil {
types/identity.go
+14
-10
@@ -13,20 +13,24 @@ const (
13
)
14
15
type Identity struct {
16
- Name string `json:"name,omitempty"`
17
- Address string `json:"address,omitempty"`
18
- PublicKey string `json:"-"`
19
- PrivateKey string `json:"-"`
20
- TokenSecret string `json:"-"`
16
+ Name string `json:"name,omitempty"`
17
+ Address string `json:"address,omitempty"`
18
+ PublicKey string `json:"-"`
19
+ PrivateKey string `json:"-"`
20
+ Mnemonic string `json:"-"`
21
+ DerivationPath string `json:"-"`
22
+ TokenSecret string `json:"-"`
23
}
24
25
func (i Identity) Copy() Identity {
26
return Identity{
25
- Name: i.Name,
26
- Address: i.Address,
27
- PublicKey: i.PublicKey,
28
- PrivateKey: i.PrivateKey,
29
- TokenSecret: i.TokenSecret,
27
+ Name: i.Name,
28
+ Address: i.Address,
29
+ PublicKey: i.PublicKey,
30
+ PrivateKey: i.PrivateKey,
31
+ Mnemonic: i.Mnemonic,
32
+ DerivationPath: i.DerivationPath,
33
+ TokenSecret: i.TokenSecret,
34
}
35
}
36