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