feat(identity): implement identity management include secp256k1 and wireguards

Kim committed May 13, 2026 at 17:04 UTC 820ab736ad6f264fd73a09b8a52d0fa939a642db
30 files changed +919 -641
cmd/relay-server/admin.go
+7 -6
@@ -9,6 +9,7 @@ import (
9 "sync"
10 "time"
11
12 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
13 "github.com/gosuda/portal-tunnel/v2/portal/policy"
14 "github.com/gosuda/portal-tunnel/v2/types"
15 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -251,7 +252,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
252 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidAddress, "invalid address")
253 return
254 }
254 - identity, err := utils.NormalizeIdentity(types.Identity{
255 + normalizedIdentity, err := identity.NormalizeIdentity(types.Identity{
256 Name: name,
257 Address: address,
258 })
@@ -259,7 +260,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
260 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid identity")
261 return
262 }
262 - identityKey := identity.Key()
263 + identityKey := normalizedIdentity.Key()
264 approver := runtime.Approver()
265
266 type identityAction struct {
@@ -486,12 +487,12 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
487 }
488 }
489 runtime.Approver().SetDecisions(
489 - utils.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
490 - utils.NormalizeIdentityKeys(s.DeniedIdentityKeys),
490 + identity.NormalizeIdentityKeys(s.ApprovedIdentityKeys),
491 + identity.NormalizeIdentityKeys(s.DeniedIdentityKeys),
492 )
492 - runtime.SetBannedIdentityKeys(utils.NormalizeIdentityKeys(s.BannedIdentityKeys))
493 + runtime.SetBannedIdentityKeys(identity.NormalizeIdentityKeys(s.BannedIdentityKeys))
494 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
494 - runtime.BPSManager().SetIdentityBPSLimits(utils.NormalizeIdentityKeyBPS(s.IdentityBPS))
495 + runtime.BPSManager().SetIdentityBPSLimits(identity.NormalizeIdentityKeyBPS(s.IdentityBPS))
496 applyOptionalPolicy(s.UDPEnabled, s.UDPMaxLeases, runtime.IsUDPEnabled, runtime.UDPMaxLeases, runtime.SetUDPPolicy)
497 applyOptionalPolicy(s.TCPPortEnabled, s.TCPPortMaxLeases, runtime.IsTCPPortEnabled, runtime.TCPPortMaxLeases, runtime.SetTCPPortPolicy)
498 return nil
cmd/relay-server/frontend.go
+4 -3
@@ -19,6 +19,7 @@ import (
19
20 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
21 "github.com/gosuda/portal-tunnel/v2/portal"
22 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
23 "github.com/gosuda/portal-tunnel/v2/types"
24 "github.com/gosuda/portal-tunnel/v2/utils"
25 )
@@ -51,7 +52,7 @@ func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageE
52 if runtime == nil {
53 return nil, errors.New("frontend requires policy runtime")
54 }
54 - adminSettingsPath := utils.ResolveRelayAdminSettingsPath(identityPath)
55 + adminSettingsPath := identity.ResolveRelayAdminSettingsPath(identityPath)
56 if adminSettingsPath == "" {
57 return nil, errors.New("frontend requires identity path")
58 }
@@ -59,8 +60,8 @@ func NewFrontend(server *portal.Server, identityPath string, defaultLandingPageE
60 if err != nil {
61 return nil, err
62 }
62 - identity := server.RelayIdentity()
63 - auth, err := newAdminAuth(identity.AdminSecretKey)
63 + relayIdentity := server.RelayIdentity()
64 + auth, err := newAdminAuth(relayIdentity.AdminSecretKey)
65 if err != nil {
66 return nil, err
67 }
cmd/relay-server/main.go
+2 -1
@@ -14,6 +14,7 @@ import (
14
15 "github.com/gosuda/portal-tunnel/v2/portal"
16 "github.com/gosuda/portal-tunnel/v2/portal/acme"
17 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
18 "github.com/gosuda/portal-tunnel/v2/portal/overlay"
19 "github.com/gosuda/portal-tunnel/v2/types"
20 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -110,7 +111,7 @@ func runServeCommand(args []string) error {
111 printRootUsage(os.Stderr)
112 return err
113 }
113 - cfg.IdentityPath = utils.ResolveRelayStateDir(cfg.IdentityPath)
114 + cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath)
115
116 log.Info().
117 Str("release_version", types.ReleaseVersion).
portal/acme/acme.go
+3 -2
@@ -25,6 +25,7 @@ import (
25 "github.com/go-acme/lego/v4/registration"
26 "github.com/rs/zerolog/log"
27
28 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
29 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
30 "github.com/gosuda/portal-tunnel/v2/utils"
31 )
@@ -162,7 +163,7 @@ func NewManager(cfg Config) (*Manager, error) {
163 if cfg.ENSGaslessAddress == "" {
164 return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
165 }
165 - address, err := utils.NormalizeEVMAddress(cfg.ENSGaslessAddress)
166 + address, err := identity.NormalizeEVMAddress(cfg.ENSGaslessAddress)
167 if err != nil {
168 return nil, fmt.Errorf("normalize ens gasless address: %w", err)
169 }
@@ -624,7 +625,7 @@ func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address
625 return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
626 }
627
627 - address, err := utils.NormalizeEVMAddress(address)
628 + address, err := identity.NormalizeEVMAddress(address)
629 if err != nil {
630 return fmt.Errorf("normalize ens gasless address: %w", err)
631 }
portal/api_server.go
+3 -2
@@ -15,6 +15,7 @@ import (
15 "github.com/rs/zerolog/log"
16
17 "github.com/gosuda/portal-tunnel/v2/portal/auth"
18 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
20 "github.com/gosuda/portal-tunnel/v2/types"
21 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -191,7 +192,7 @@ func (s *Server) handleRelayDiscoveryAnnounce(w http.ResponseWriter, r *http.Req
192 return
193 }
194
194 - desc, err := utils.NormalizeDescriptor(req.Descriptor)
195 + desc, err := identity.NormalizeRelayDescriptor(req.Descriptor)
196 if err != nil {
197 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
198 return
@@ -459,7 +460,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
460 utils.InvalidRequestError(fmt.Errorf("forward relay: %w", err)).Write(w)
461 return
462 }
462 - if err := s.syncOverlayPeers(s.relaySet.OverlayPeerStates()); err != nil {
463 + if err := s.syncOverlayPeers(s.relaySet.OverlayPeerDescriptor()); err != nil {
464 utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
465 return
466 }
portal/auth/hop_route.go
+19 -10
@@ -6,6 +6,7 @@ import (
6 "strings"
7 "time"
8
9 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
10 "github.com/gosuda/portal-tunnel/v2/types"
11 "github.com/gosuda/portal-tunnel/v2/utils"
12 )
@@ -15,7 +16,7 @@ var ErrHopRouteSignatureInvalid = errors.New("hop route signature is invalid")
16 func normalizeHopRoute(route *types.HopRoute, requireOwner bool) error {
17 ownerPublicKey := strings.ToLower(utils.TrimHexPrefix(strings.TrimSpace(route.OwnerPublicKey)))
18 if ownerPublicKey != "" {
18 - if _, err := utils.ParseSecp256k1PublicKeyHex(ownerPublicKey); err != nil {
19 + if _, err := identity.ParseSecp256k1PublicKeyHex(ownerPublicKey); err != nil {
20 return fmt.Errorf("hop route owner public key: %w", err)
21 }
22 } else if requireOwner {
@@ -40,7 +41,10 @@ func normalizeHopRoute(route *types.HopRoute, requireOwner bool) error {
41 return nil
42 }
43
43 -func SignHopRoute(method string, route types.HopRoute, identity types.Identity, expiresAt time.Time) (types.HopRoute, error) {
44 +func SignHopRoute(method string, route types.HopRoute, authority identity.Authority, expiresAt time.Time) (types.HopRoute, error) {
45 + if authority == nil {
46 + return types.HopRoute{}, errors.New("hop route signing authority is required")
47 + }
48 route.ExpiresAt = expiresAt.UTC()
49 route.Signature = ""
50 route.OwnerPublicKey = ""
@@ -48,20 +52,25 @@ func SignHopRoute(method string, route types.HopRoute, identity types.Identity,
52 if err := normalizeHopRoute(&route, false); err != nil {
53 return types.HopRoute{}, err
54 }
51 - identity, err := utils.NormalizeStoredIdentity(identity)
52 - if err != nil {
53 - return types.HopRoute{}, err
54 - }
55 - if strings.TrimSpace(identity.PrivateKey) == "" || strings.TrimSpace(identity.PublicKey) == "" {
55 + ownerIdentity := authority.Identity()
56 + ownerPublicKey := strings.ToLower(utils.TrimHexPrefix(strings.TrimSpace(ownerIdentity.PublicKey)))
57 + if ownerPublicKey == "" {
58 return types.HopRoute{}, errors.New("hop route owner identity is required")
59 }
60 + if _, err := identity.ParseSecp256k1PublicKeyHex(ownerPublicKey); err != nil {
61 + return types.HopRoute{}, fmt.Errorf("hop route owner public key: %w", err)
62 + }
63
59 - route.OwnerPublicKey = identity.PublicKey
64 + route.OwnerPublicKey = ownerPublicKey
65 payload, err := types.HopRouteBytes(method, route)
66 if err != nil {
67 return types.HopRoute{}, err
68 }
64 - route.Signature, err = utils.SignSHA256Secp256k1DER(payload, identity.PrivateKey)
69 + signature, err := authority.SignSHA256Secp256k1(payload)
70 + if err != nil {
71 + return types.HopRoute{}, err
72 + }
73 + route.Signature, err = signature.DERHex()
74 if err != nil {
75 return types.HopRoute{}, err
76 }
@@ -79,7 +88,7 @@ func VerifyHopRoute(method string, route types.HopRoute) (types.HopRoute, error)
88 if err != nil {
89 return types.HopRoute{}, err
90 }
82 - if err := utils.VerifySHA256Secp256k1DER(payload, route.OwnerPublicKey, signature); err != nil {
91 + if err := identity.VerifySHA256Secp256k1DER(payload, route.OwnerPublicKey, signature); err != nil {
92 return types.HopRoute{}, ErrHopRouteSignatureInvalid
93 }
94 route.Signature = signature
portal/auth/lease_token.go
+18 -16
@@ -9,6 +9,7 @@ import (
9 jose "github.com/go-jose/go-jose/v4"
10 "github.com/go-jose/go-jose/v4/jwt"
11
12 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
13 "github.com/gosuda/portal-tunnel/v2/types"
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
@@ -24,12 +25,11 @@ type LeaseAccessTokenClaims struct {
25 }
26
27 type es256kOpaqueSigner struct {
27 - keyID string
28 - privateKey *secp256k1.PrivateKey
28 + authority identity.Authority
29 }
30
31 func (s *es256kOpaqueSigner) Public() *jose.JSONWebKey {
32 - return &jose.JSONWebKey{KeyID: s.keyID}
32 + return &jose.JSONWebKey{}
33 }
34
35 func (s *es256kOpaqueSigner) Algs() []jose.SignatureAlgorithm {
@@ -40,10 +40,14 @@ func (s *es256kOpaqueSigner) SignPayload(payload []byte, alg jose.SignatureAlgor
40 if alg != leaseTokenAlgorithm {
41 return nil, jose.ErrUnsupportedAlgorithm
42 }
43 - if s == nil || s.privateKey == nil {
43 + if s == nil || s.authority == nil {
44 return nil, errors.New("signing key is required")
45 }
46 - return utils.SignSHA256Secp256k1Raw64(payload, s.privateKey)
46 + signature, err := s.authority.SignSHA256Secp256k1(payload)
47 + if err != nil {
48 + return nil, err
49 + }
50 + return signature.Raw64()
51 }
52
53 type es256kOpaqueVerifier struct {
@@ -57,8 +61,8 @@ func (v *es256kOpaqueVerifier) VerifyPayload(payload []byte, signature []byte, a
61 if v == nil || v.publicKey == nil {
62 return errors.New("verification key is required")
63 }
60 - if err := utils.VerifySHA256Secp256k1Raw64(payload, signature, v.publicKey); err != nil {
61 - if errors.Is(err, utils.ErrSecp256k1SignatureInvalid) {
64 + if err := identity.VerifySHA256Secp256k1Raw64(payload, signature, v.publicKey); err != nil {
65 + if errors.Is(err, identity.ErrSecp256k1SignatureInvalid) {
66 return errors.New("token signature is invalid")
67 }
68 return err
@@ -66,12 +70,11 @@ func (v *es256kOpaqueVerifier) VerifyPayload(payload []byte, signature []byte, a
70 return nil
71 }
72
69 -func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.Identity, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
70 - privateKey, _, err := utils.ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
71 - if err != nil {
72 - return "", LeaseAccessTokenClaims{}, err
73 +func IssueLeaseAccessToken(authority identity.Authority, issuer string, leaseIdentity types.Identity, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
74 + if authority == nil {
75 + return "", LeaseAccessTokenClaims{}, errors.New("lease token signing authority is required")
76 }
74 - normalizedIdentity, err := utils.NormalizeIdentity(identity)
77 + normalizedIdentity, err := identity.NormalizeIdentity(leaseIdentity)
78 if err != nil {
79 return "", LeaseAccessTokenClaims{}, err
80 }
@@ -79,8 +82,7 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.I
82 signer, err := jose.NewSigner(jose.SigningKey{
83 Algorithm: leaseTokenAlgorithm,
84 Key: &es256kOpaqueSigner{
82 - keyID: strings.TrimSpace(keyID),
83 - privateKey: privateKey,
85 + authority: authority,
86 },
87 }, (&jose.SignerOptions{}).WithType("JWT"))
88 if err != nil {
@@ -110,7 +112,7 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.I
112 }
113
114 func VerifyLeaseAccessToken(token, publicKeyHex, issuer string, now time.Time) (LeaseAccessTokenClaims, error) {
113 - publicKey, err := utils.ParseSecp256k1PublicKeyHex(publicKeyHex)
115 + publicKey, err := identity.ParseSecp256k1PublicKeyHex(publicKeyHex)
116 if err != nil {
117 return LeaseAccessTokenClaims{}, err
118 }
@@ -124,7 +126,7 @@ func VerifyLeaseAccessToken(token, publicKeyHex, issuer string, now time.Time) (
126 if err := parsed.Claims(&es256kOpaqueVerifier{publicKey: publicKey}, &claims); err != nil {
127 return LeaseAccessTokenClaims{}, err
128 }
127 - normalizedClaimsIdentity, err := utils.NormalizeIdentity(claims.Identity)
129 + normalizedClaimsIdentity, err := identity.NormalizeIdentity(claims.Identity)
130 if err != nil {
131 return LeaseAccessTokenClaims{}, err
132 }
portal/auth/register_challenge.go
+2 -1
@@ -8,6 +8,7 @@ import (
8
9 "github.com/spruceid/siwe-go"
10
11 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
12 "github.com/gosuda/portal-tunnel/v2/types"
13 "github.com/gosuda/portal-tunnel/v2/utils"
14 )
@@ -29,7 +30,7 @@ type RegisterChallenge struct {
30 }
31
32 func NewRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string, now time.Time, ttl time.Duration) (*RegisterChallenge, error) {
32 - normalizedIdentity, err := utils.NormalizeIdentity(req.Identity)
33 + normalizedIdentity, err := identity.NormalizeIdentity(req.Identity)
34 if err != nil {
35 return nil, err
36 }
portal/auth/relay_descriptor.go
+25 -15
@@ -7,38 +7,48 @@ import (
7 "fmt"
8 "strings"
9
10 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
11 "github.com/gosuda/portal-tunnel/v2/types"
11 - "github.com/gosuda/portal-tunnel/v2/utils"
12 )
13
14 // SignRelayDescriptor returns a copy of desc with its Signature field
15 -// populated by signing the canonical bytes with the supplied secp256k1
16 -// private key (hex encoded). The signature is recoverable, so verifiers do
17 -// not need to know the public key out of band; they recover it from the
18 -// signature and check it derives the descriptor's Address field.
19 -func SignRelayDescriptor(desc types.RelayDescriptor, privateKeyHex string) (types.RelayDescriptor, error) {
20 - privateKey, _, err := utils.ParseSecp256k1PrivateKeyHex(privateKeyHex, true)
21 - if err != nil {
22 - return types.RelayDescriptor{}, fmt.Errorf("relay descriptor signing key: %w", err)
15 +// populated by signing the canonical bytes with authority. The signature is
16 +// recoverable, so verifiers do not need to know the public key out of band;
17 +// they recover it from the signature and check it derives the descriptor's
18 +// Address field.
19 +func SignRelayDescriptor(desc types.RelayDescriptor, authority identity.Authority) (types.RelayDescriptor, error) {
20 + if authority == nil {
21 + return types.RelayDescriptor{}, errors.New("relay descriptor signing authority is required")
22 + }
23 + signingIdentity := authority.Identity()
24 + if desc.Address == "" {
25 + desc.Address = signingIdentity.Address
26 }
27
28 desc.Signature = ""
26 - normalized, err := utils.NormalizeDescriptor(desc)
29 + normalized, err := identity.NormalizeRelayDescriptor(desc)
30 if err != nil {
31 return types.RelayDescriptor{}, fmt.Errorf("normalize relay descriptor for signing: %w", err)
32 }
33 + if signingIdentity.Address != "" && !strings.EqualFold(strings.TrimSpace(signingIdentity.Address), strings.TrimSpace(normalized.Address)) {
34 + return types.RelayDescriptor{}, errors.New("relay descriptor address does not match signing authority")
35 + }
36 desc = normalized
37
38 canonical, err := types.CanonicalBytes(desc)
39 if err != nil {
40 return types.RelayDescriptor{}, fmt.Errorf("canonicalize relay descriptor: %w", err)
41 }
36 - signature, err := utils.SignSHA256Secp256k1Compact(canonical, privateKey, true)
42 + signature, err := authority.SignSHA256Secp256k1(canonical)
43 + if err != nil {
44 + return types.RelayDescriptor{}, err
45 + }
46 + compactSignature, err := signature.Compact()
47 if err != nil {
48 return types.RelayDescriptor{}, err
49 }
50
41 - desc.Signature = base64.StdEncoding.EncodeToString(signature)
51 + desc.Signature = base64.StdEncoding.EncodeToString(compactSignature)
52 return desc, nil
53 }
54
@@ -59,7 +69,7 @@ func VerifyRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, e
69
70 unsignedCopy := desc
71 unsignedCopy.Signature = ""
62 - normalized, err := utils.NormalizeDescriptor(unsignedCopy)
72 + normalized, err := identity.NormalizeRelayDescriptor(unsignedCopy)
73 if err != nil {
74 return types.RelayDescriptor{}, fmt.Errorf("relay descriptor signature is invalid: normalize: %w", err)
75 }
@@ -68,13 +78,13 @@ func VerifyRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, e
78 return types.RelayDescriptor{}, fmt.Errorf("canonicalize relay descriptor: %w", err)
79 }
80
71 - publicKey, err := utils.RecoverSHA256Secp256k1Compact(canonical, signature)
81 + publicKey, err := identity.RecoverSHA256Secp256k1Compact(canonical, signature)
82 if err != nil {
83 return types.RelayDescriptor{}, fmt.Errorf("relay descriptor signature is invalid: %w", err)
84 }
85
86 publicKeyHex := hex.EncodeToString(publicKey.SerializeCompressed())
77 - derivedAddress, err := utils.AddressFromCompressedPublicKeyHex(publicKeyHex)
87 + derivedAddress, err := identity.AddressFromCompressedPublicKeyHex(publicKeyHex)
88 if err != nil {
89 return types.RelayDescriptor{}, fmt.Errorf("derive address from recovered key: %w", err)
90 }
portal/discovery/announce_test.go
+9 -5
@@ -5,17 +5,17 @@ import (
5 "time"
6
7 "github.com/gosuda/portal-tunnel/v2/portal/auth"
8 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
9 "github.com/gosuda/portal-tunnel/v2/types"
9 - "github.com/gosuda/portal-tunnel/v2/utils"
10 )
11
12 func mustSigningIdentity(t *testing.T) types.Identity {
13 t.Helper()
14 - identity, err := utils.ResolveSecp256k1Identity("")
14 + signingIdentity, err := identity.ResolveSecp256k1Identity("")
15 if err != nil {
16 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
16 + t.Fatalf("identity.ResolveSecp256k1Identity() error = %v", err)
17 }
18 - return identity
18 + return signingIdentity
19 }
20
21 func mustUnsignedDescriptor(t *testing.T, signing types.Identity, relayURL string) types.RelayDescriptor {
@@ -32,13 +32,17 @@ func mustUnsignedDescriptor(t *testing.T, signing types.Identity, relayURL strin
32
33 func mustSignedDescriptor(t *testing.T, signing types.Identity, relayURL string, issuedAt time.Time) types.RelayDescriptor {
34 t.Helper()
35 + authority, err := identity.NewLocalAuthority(signing)
36 + if err != nil {
37 + t.Fatalf("identity.NewLocalAuthority() error = %v", err)
38 + }
39 signed, err := auth.SignRelayDescriptor(types.RelayDescriptor{
40 Address: signing.Address,
41 Version: types.DiscoveryVersion,
42 IssuedAt: issuedAt,
43 ExpiresAt: issuedAt.Add(DiscoveryDescriptorTTL),
44 APIHTTPSAddr: relayURL,
41 - }, signing.PrivateKey)
45 + }, authority)
46 if err != nil {
47 t.Fatalf("SignRelayDescriptor() error = %v", err)
48 }
portal/discovery/policy_test.go
+8 -4
@@ -6,16 +6,20 @@ import (
6 "time"
7
8 "github.com/gosuda/portal-tunnel/v2/portal/auth"
9 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
10 "github.com/gosuda/portal-tunnel/v2/types"
10 - "github.com/gosuda/portal-tunnel/v2/utils"
11 )
12
13 func mustPolicyRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
14 t.Helper()
15
16 - signing, err := utils.ResolveSecp256k1Identity("")
16 + signing, err := identity.ResolveSecp256k1Identity("")
17 if err != nil {
18 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
18 + t.Fatalf("identity.ResolveSecp256k1Identity() error = %v", err)
19 + }
20 + authority, err := identity.NewLocalAuthority(signing)
21 + if err != nil {
22 + t.Fatalf("identity.NewLocalAuthority() error = %v", err)
23 }
24 now := time.Now().UTC()
25 signed, err := auth.SignRelayDescriptor(types.RelayDescriptor{
@@ -24,7 +28,7 @@ func mustPolicyRelayDescriptor(t *testing.T, relayURL string) types.RelayDescrip
28 IssuedAt: now,
29 ExpiresAt: now.Add(time.Hour),
30 APIHTTPSAddr: relayURL,
27 - }, signing.PrivateKey)
31 + }, authority)
32 if err != nil {
33 t.Fatalf("SignRelayDescriptor() error = %v", err)
34 }
portal/discovery/refresher.go
+8 -4
@@ -21,7 +21,7 @@ const (
21
22 type OverlayRuntime interface {
23 DiscoverRelay(context.Context, types.RelayDescriptor) (types.DiscoveryResponse, error)
24 - Sync([]RelayState) error
24 + Sync([]types.RelayDescriptor) error
25 }
26
27 type Refresher struct {
@@ -206,11 +206,15 @@ func (r *Refresher) refreshHTTPS(ctx context.Context) error {
206 }
207
208 func (r *Refresher) refreshOverlay(ctx context.Context) error {
209 - states := r.relaySet.OverlayPeerStates()
209 + states := r.relaySet.overlayPeerRelayStates()
210 if len(states) == 0 {
211 return nil
212 }
213 - if err := r.overlay.Sync(states); err != nil {
213 + descriptors := make([]types.RelayDescriptor, 0, len(states))
214 + for _, state := range states {
215 + descriptors = append(descriptors, state.Descriptor)
216 + }
217 + if err := r.overlay.Sync(descriptors); err != nil {
218 return err
219 }
220 relaySetChanged := false
@@ -251,7 +255,7 @@ func (r *Refresher) refreshOverlay(ctx context.Context) error {
255 if !relaySetChanged {
256 return nil
257 }
254 - if err := r.overlay.Sync(r.relaySet.OverlayPeerStates()); err != nil {
258 + if err := r.overlay.Sync(r.relaySet.OverlayPeerDescriptor()); err != nil {
259 return err
260 }
261 return nil
portal/discovery/relayset.go
+15 -12
@@ -287,7 +287,7 @@ func (s *RelaySet) PriorityRelaysWithTrace(clientState ClientState) ([]string, t
287 Int("output_count", len(trace.OutputURLs)).
288 Str("mode", trace.Mode).
289 Bool("congested", trace.Congested).
290 - Strs("top3", first3(trace.OutputURLs)).
290 + Strs("relay_urls", trace.OutputURLs).
291 Msg("relay selection")
292 return result, trace
293 }
@@ -322,7 +322,7 @@ func (s *RelaySet) PriorityMultiHopWithTrace(clientState ClientState) ([]string,
322 Int("output_count", len(trace.OutputURLs)).
323 Str("mode", trace.Mode).
324 Bool("congested", trace.Congested).
325 - Strs("top3", first3(trace.OutputURLs)).
325 + Strs("relay_urls", trace.OutputURLs).
326 Msg("relay selection")
327 return result, trace
328 }
@@ -335,16 +335,7 @@ func (s *RelaySet) PriorityMultiHop(clientState ClientState) []string {
335 return out
336 }
337
338 -// first3 returns a slice containing the first three elements of s, or all
339 -// elements if s has fewer than three. It never modifies the input slice.
340 -func first3(s []string) []string {
341 - if len(s) <= 3 {
342 - return s
343 - }
344 - return s[:3]
345 -}
346 -
347 -func (s *RelaySet) OverlayPeerStates() []RelayState {
338 +func (s *RelaySet) overlayPeerRelayStates() []RelayState {
339 now := time.Now().UTC()
340 s.mu.RLock()
341 out := make([]RelayState, 0, len(s.relays))
@@ -361,6 +352,18 @@ func (s *RelaySet) OverlayPeerStates() []RelayState {
352 return out
353 }
354
355 +func (s *RelaySet) OverlayPeerDescriptor() []types.RelayDescriptor {
356 + states := s.overlayPeerRelayStates()
357 + if len(states) == 0 {
358 + return nil
359 + }
360 + out := make([]types.RelayDescriptor, 0, len(states))
361 + for _, state := range states {
362 + out = append(out, state.Descriptor)
363 + }
364 + return out
365 +}
366 +
367 func (s *RelaySet) OverlayRelayDescriptor(relayURL string, now time.Time) (types.RelayDescriptor, bool) {
368 if now.IsZero() {
369 now = time.Now().UTC()
portal/identity/authority.go new
+53
@@ -0,0 +1,53 @@
1 +package identity
2 +
3 +import (
4 + "errors"
5 +
6 + "github.com/gosuda/portal-tunnel/v2/types"
7 +)
8 +
9 +type Authority interface {
10 + Identity() types.Identity
11 + SignEthereumPersonalMessage(message string) (string, error)
12 + SignSHA256Secp256k1(payload []byte) (Secp256k1Signature, error)
13 +}
14 +
15 +type LocalAuthority struct {
16 + identity types.Identity
17 +}
18 +
19 +func NewLocalAuthority(raw types.Identity) (LocalAuthority, error) {
20 + normalized, err := normalizeStoredIdentity(raw)
21 + if err != nil {
22 + return LocalAuthority{}, err
23 + }
24 + if normalized.PrivateKey == "" {
25 + return LocalAuthority{}, errors.New("authority private key is required")
26 + }
27 + if normalized.PublicKey == "" {
28 + return LocalAuthority{}, errors.New("authority public key is required")
29 + }
30 + if normalized.Address == "" {
31 + return LocalAuthority{}, errors.New("authority address is required")
32 + }
33 + return LocalAuthority{identity: normalized}, nil
34 +}
35 +
36 +func (a LocalAuthority) Identity() types.Identity {
37 + identity := a.identity.Copy()
38 + identity.PrivateKey = ""
39 + identity.TokenSecret = ""
40 + return identity
41 +}
42 +
43 +func (a LocalAuthority) SignEthereumPersonalMessage(message string) (string, error) {
44 + return signEthereumPersonalMessage(message, a.identity.PrivateKey)
45 +}
46 +
47 +func (a LocalAuthority) SignSHA256Secp256k1(payload []byte) (Secp256k1Signature, error) {
48 + privateKey, _, err := parseSecp256k1PrivateKeyHex(a.identity.PrivateKey, true)
49 + if err != nil {
50 + return Secp256k1Signature{}, err
51 + }
52 + return signSHA256Secp256k1(payload, privateKey)
53 +}
portal/identity/secp256k1.go renamed
+109 -156
@@ -1,42 +1,79 @@
1 -package utils
1 +package identity
2
3 import (
4 - "crypto/rand"
4 "crypto/sha256"
6 - "encoding/base64"
5 "encoding/hex"
6 "errors"
7 "fmt"
10 - "net/netip"
8 "strings"
9
10 "github.com/decred/dcrd/dcrec/secp256k1/v4"
11 "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
15 - "golang.org/x/crypto/curve25519"
12 "golang.org/x/crypto/sha3"
13
14 "github.com/gosuda/portal-tunnel/v2/types"
15 )
16
17 const (
22 - // CompactSecp256k1SignatureSize is the byte length of a compact
18 + // compactSecp256k1SignatureSize is the byte length of a compact
19 // recoverable secp256k1 ECDSA signature.
24 - CompactSecp256k1SignatureSize = 65
25 - // RawSecp256k1SignatureSize is the byte length of the JOSE ES256K
20 + compactSecp256k1SignatureSize = 65
21 + // rawSecp256k1SignatureSize is the byte length of the JOSE ES256K
22 // signature form, r || s with no recovery header.
27 - RawSecp256k1SignatureSize = 64
23 + rawSecp256k1SignatureSize = 64
24 )
25
26 // ErrSecp256k1SignatureInvalid marks a well-formed signature that does not
27 // verify for the payload and public key.
28 var ErrSecp256k1SignatureInvalid = errors.New("signature is invalid")
29
30 +type Secp256k1Signature struct {
31 + compact []byte
32 +}
33 +
34 +func newSecp256k1SignatureFromCompact(compact []byte) (Secp256k1Signature, error) {
35 + normalized, err := copySecp256k1CompactSignature(compact)
36 + if err != nil {
37 + return Secp256k1Signature{}, err
38 + }
39 + return Secp256k1Signature{compact: normalized}, nil
40 +}
41 +
42 +func (s Secp256k1Signature) Compact() ([]byte, error) {
43 + return copySecp256k1CompactSignature(s.compact)
44 +}
45 +
46 +func (s Secp256k1Signature) Raw64() ([]byte, error) {
47 + compact, err := copySecp256k1CompactSignature(s.compact)
48 + if err != nil {
49 + return nil, err
50 + }
51 +
52 + signature := make([]byte, rawSecp256k1SignatureSize)
53 + copy(signature[:32], compact[1:33])
54 + copy(signature[32:], compact[33:65])
55 + return signature, nil
56 +}
57 +
58 +func (s Secp256k1Signature) DERHex() (string, error) {
59 + raw, err := s.Raw64()
60 + if err != nil {
61 + return "", err
62 + }
63 +
64 + signature, err := secp256k1SignatureFromRaw64(raw)
65 + if err != nil {
66 + return "", err
67 + }
68 + return hex.EncodeToString(signature.Serialize()), nil
69 +}
70 +
71 func NormalizeEVMAddress(raw string) (string, error) {
72 trimmed := strings.TrimSpace(raw)
73 if trimmed == "" {
74 return "", errors.New("address is required")
75 }
39 - hexPart := TrimHexPrefix(trimmed)
76 + hexPart := trimHexPrefix(trimmed)
77 if hexPart == trimmed {
78 return "", errors.New("address must start with 0x")
79 }
@@ -98,8 +135,8 @@ func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
135 return NormalizeEVMAddress("0x" + hex.EncodeToString(hash[len(hash)-20:]))
136 }
137
101 -func SignEthereumPersonalMessage(message, privateKeyHex string) (string, error) {
102 - privateKey, _, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
138 +func signEthereumPersonalMessage(message, privateKeyHex string) (string, error) {
139 + privateKey, _, err := parseSecp256k1PrivateKeyHex(privateKeyHex, false)
140 if err != nil {
141 return "", err
142 }
@@ -133,7 +170,7 @@ func ResolveSecp256k1Identity(rawPrivateKey string) (types.Identity, error) {
170 privateKeyHex = hex.EncodeToString(privateKey.Serialize())
171 }
172
136 - privateKey, normalizedKeyHex, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, true)
173 + privateKey, normalizedKeyHex, err := parseSecp256k1PrivateKeyHex(privateKeyHex, true)
174 if err != nil {
175 return types.Identity{}, err
176 }
@@ -151,49 +188,18 @@ func ResolveSecp256k1Identity(rawPrivateKey string) (types.Identity, error) {
188 }, nil
189 }
190
154 -func SignSHA256Secp256k1DER(payload []byte, privateKeyHex string) (string, error) {
155 - privateKey, _, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
156 - if err != nil {
157 - return "", err
158 - }
159 -
160 - hash := sha256.Sum256(payload)
161 - signature := ecdsa.Sign(privateKey, hash[:])
162 - return hex.EncodeToString(signature.Serialize()), nil
163 -}
164 -
165 -// SignSHA256Secp256k1Compact signs the SHA-256 digest of payload and returns
166 -// the compact recoverable secp256k1 ECDSA signature.
167 -func SignSHA256Secp256k1Compact(payload []byte, privateKey *secp256k1.PrivateKey, compressed bool) ([]byte, error) {
191 +func signSHA256Secp256k1(payload []byte, privateKey *secp256k1.PrivateKey) (Secp256k1Signature, error) {
192 if privateKey == nil {
169 - return nil, errors.New("signing key is required")
193 + return Secp256k1Signature{}, errors.New("signing key is required")
194 }
195 hash := sha256.Sum256(payload)
172 - signature := ecdsa.SignCompact(privateKey, hash[:], compressed)
173 - if len(signature) != CompactSecp256k1SignatureSize {
174 - return nil, errors.New("invalid compact signature length")
175 - }
176 - return signature, nil
177 -}
178 -
179 -// SignSHA256Secp256k1Raw64 signs the SHA-256 digest of payload and returns
180 -// the raw r || s signature form used by ES256K.
181 -func SignSHA256Secp256k1Raw64(payload []byte, privateKey *secp256k1.PrivateKey) ([]byte, error) {
182 - compact, err := SignSHA256Secp256k1Compact(payload, privateKey, false)
183 - if err != nil {
184 - return nil, err
185 - }
186 -
187 - signature := make([]byte, RawSecp256k1SignatureSize)
188 - copy(signature[:32], compact[1:33])
189 - copy(signature[32:], compact[33:65])
190 - return signature, nil
196 + return newSecp256k1SignatureFromCompact(ecdsa.SignCompact(privateKey, hash[:], true))
197 }
198
199 // RecoverSHA256Secp256k1Compact recovers the public key from a compact
200 // recoverable signature over the SHA-256 digest of payload.
201 func RecoverSHA256Secp256k1Compact(payload, signature []byte) (*secp256k1.PublicKey, error) {
196 - if len(signature) != CompactSecp256k1SignatureSize {
202 + if len(signature) != compactSecp256k1SignatureSize {
203 return nil, errors.New("invalid compact signature length")
204 }
205
@@ -214,19 +220,58 @@ func VerifySHA256Secp256k1Raw64(payload, signature []byte, publicKey *secp256k1.
220 if publicKey == nil {
221 return errors.New("verification key is required")
222 }
217 - if len(signature) != RawSecp256k1SignatureSize {
223 + if len(signature) != rawSecp256k1SignatureSize {
224 return errors.New("invalid es256k signature length")
225 }
226
227 + parsed, err := secp256k1SignatureFromRaw64(signature)
228 + if err != nil {
229 + return err
230 + }
231 +
232 + return verifySHA256Secp256k1Signature(payload, parsed, publicKey)
233 +}
234 +
235 +func copySecp256k1CompactSignature(compact []byte) ([]byte, error) {
236 + if len(compact) != compactSecp256k1SignatureSize {
237 + return nil, errors.New("invalid compact signature length")
238 + }
239 + if _, err := secp256k1CompactRecoveryID(compact[0]); err != nil {
240 + return nil, err
241 + }
242 + if _, err := secp256k1SignatureFromRaw64(compact[1:]); err != nil {
243 + return nil, err
244 + }
245 +
246 + normalized := make([]byte, compactSecp256k1SignatureSize)
247 + copy(normalized, compact)
248 + return normalized, nil
249 +}
250 +
251 +func secp256k1CompactRecoveryID(header byte) (byte, error) {
252 + switch {
253 + case header >= 27 && header <= 30:
254 + return header - 27, nil
255 + case header >= 31 && header <= 34:
256 + return header - 31, nil
257 + default:
258 + return 0, errors.New("invalid compact signature header")
259 + }
260 +}
261 +
262 +func secp256k1SignatureFromRaw64(signature []byte) (*ecdsa.Signature, error) {
263 + if len(signature) != rawSecp256k1SignatureSize {
264 + return nil, errors.New("invalid es256k signature length")
265 + }
266 +
267 var r, s secp256k1.ModNScalar
268 if overflow := r.SetByteSlice(signature[:32]); overflow || r.IsZero() {
223 - return errors.New("invalid es256k signature r")
269 + return nil, errors.New("invalid es256k signature r")
270 }
271 if overflow := s.SetByteSlice(signature[32:]); overflow || s.IsZero() {
226 - return errors.New("invalid es256k signature s")
272 + return nil, errors.New("invalid es256k signature s")
273 }
228 -
229 - return verifySHA256Secp256k1Signature(payload, ecdsa.NewSignature(&r, &s), publicKey)
274 + return ecdsa.NewSignature(&r, &s), nil
275 }
276
277 func VerifySHA256Secp256k1DER(payload []byte, publicKeyHex, signatureHex string) error {
@@ -239,7 +284,7 @@ func VerifySHA256Secp256k1DER(payload []byte, publicKeyHex, signatureHex string)
284 if sigText == "" {
285 return errors.New("signature is required")
286 }
242 - sigText = TrimHexPrefix(sigText)
287 + sigText = trimHexPrefix(sigText)
288
289 sigBytes, err := hex.DecodeString(sigText)
290 if err != nil {
@@ -267,111 +312,12 @@ func verifySHA256Secp256k1Signature(payload []byte, signature *ecdsa.Signature,
312 return nil
313 }
314
270 -func NormalizeWireGuardPrivateKey(raw string) (string, error) {
271 - key, err := decodeWireGuardKey(raw)
272 - if err != nil {
273 - return "", err
274 - }
275 - clampWireGuardPrivateKey(&key)
276 - return base64.StdEncoding.EncodeToString(key[:]), nil
277 -}
278 -
279 -func GenerateWireGuardPrivateKey() (string, error) {
280 - var key [32]byte
281 - if _, err := rand.Read(key[:]); err != nil {
282 - return "", err
283 - }
284 - clampWireGuardPrivateKey(&key)
285 - return base64.StdEncoding.EncodeToString(key[:]), nil
286 -}
287 -
288 -func WireGuardPublicKeyFromPrivate(raw string) (string, error) {
289 - privateKey, err := decodeWireGuardKey(raw)
290 - if err != nil {
291 - return "", err
292 - }
293 - clampWireGuardPrivateKey(&privateKey)
294 - var publicKey [32]byte
295 - curve25519.ScalarBaseMult(&publicKey, &privateKey)
296 - return base64.StdEncoding.EncodeToString(publicKey[:]), nil
297 -}
298 -
299 -func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
300 - decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(publicKey))
301 - if err != nil {
302 - return "", errors.New("wireguard public key must be base64 encoded")
303 - }
304 - if len(decoded) != 32 {
305 - return "", errors.New("wireguard public key must be 32 bytes")
306 - }
307 -
308 - sum := sha256.Sum256(decoded)
309 - return netip.AddrFrom4([4]byte{
310 - 100,
311 - 64 + (sum[0] & 0x3f),
312 - sum[1],
313 - 1 + (sum[2] % 254),
314 - }).String(), nil
315 -}
316 -
317 -func WireGuardKeyHex(raw string) (string, error) {
318 - key, err := decodeWireGuardKey(raw)
319 - if err != nil {
320 - return "", err
321 - }
322 - return hex.EncodeToString(key[:]), nil
323 -}
324 -
325 -func decodeWireGuardKey(raw string) ([32]byte, error) {
326 - var key [32]byte
327 - value := strings.TrimSpace(raw)
328 - if value == "" {
329 - return key, errors.New("wireguard key is required")
330 - }
331 -
332 - var decoded []byte
333 - var err error
334 - if len(value) == 64 && !strings.Contains(value, "=") {
335 - decoded, err = hex.DecodeString(value)
336 - } else {
337 - decoded, err = base64.StdEncoding.DecodeString(value)
338 - }
339 - if err != nil {
340 - return key, errors.New("wireguard key must be base64 or hex encoded")
341 - }
342 - if len(decoded) != len(key) {
343 - return key, errors.New("wireguard key must be 32 bytes")
344 - }
345 - copy(key[:], decoded)
346 - return key, nil
347 -}
348 -
349 -func clampWireGuardPrivateKey(key *[32]byte) {
350 - key[0] &= 248
351 - key[31] = (key[31] & 127) | 64
352 -}
353 -
354 -func ValidateWireGuardPublicKey(raw string) error {
355 - key := strings.TrimSpace(raw)
356 - if key == "" {
357 - return errors.New("wireguard_public_key is required")
358 - }
359 - decoded, err := base64.StdEncoding.DecodeString(key)
360 - if err != nil {
361 - return errors.New("wireguard_public_key must be base64 encoded")
362 - }
363 - if len(decoded) != 32 {
364 - return errors.New("wireguard_public_key must be 32 bytes")
365 - }
366 - return nil
367 -}
368 -
315 func ParseSecp256k1PublicKeyHex(raw string) (*secp256k1.PublicKey, error) {
316 publicKeyHex := strings.TrimSpace(raw)
317 if publicKeyHex == "" {
318 return nil, errors.New("public key is required")
319 }
374 - publicKeyHex = TrimHexPrefix(publicKeyHex)
320 + publicKeyHex = trimHexPrefix(publicKeyHex)
321
322 decoded, err := hex.DecodeString(publicKeyHex)
323 if err != nil {
@@ -385,12 +331,12 @@ func ParseSecp256k1PublicKeyHex(raw string) (*secp256k1.PublicKey, error) {
331 return publicKey, nil
332 }
333
388 -func ParseSecp256k1PrivateKeyHex(raw string, requireNonZero bool) (*secp256k1.PrivateKey, string, error) {
334 +func parseSecp256k1PrivateKeyHex(raw string, requireNonZero bool) (*secp256k1.PrivateKey, string, error) {
335 privateKeyHex := strings.TrimSpace(raw)
336 if privateKeyHex == "" {
337 return nil, "", errors.New("private key is required")
338 }
393 - privateKeyHex = TrimHexPrefix(privateKeyHex)
339 + privateKeyHex = trimHexPrefix(privateKeyHex)
340
341 decoded, err := hex.DecodeString(privateKeyHex)
342 if err != nil {
@@ -423,3 +369,10 @@ func ParseSecp256k1PrivateKeyHex(raw string, requireNonZero bool) (*secp256k1.Pr
369 }
370 return key, privateKeyHex, nil
371 }
372 +
373 +func trimHexPrefix(raw string) string {
374 + if len(raw) >= 2 && raw[0] == '0' && (raw[1] == 'x' || raw[1] == 'X') {
375 + return raw[2:]
376 + }
377 + return raw
378 +}
portal/identity/store.go renamed
+194 -257
@@ -1,4 +1,4 @@
1 -package utils
1 +package identity
2
3 import (
4 "encoding/json"
@@ -6,18 +6,18 @@ import (
6 "fmt"
7 "math"
8 "net"
9 - "net/url"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "github.com/gosuda/portal-tunnel/v2/types"
14 + "github.com/gosuda/portal-tunnel/v2/utils"
15 )
16
17 func NormalizeIdentity(identity types.Identity) (types.Identity, error) {
18 normalized := identity.Copy()
19
20 - name, err := NormalizeDNSLabel(identity.Name)
20 + name, err := utils.NormalizeDNSLabel(identity.Name)
21 if err != nil {
22 return types.Identity{}, err
23 }
@@ -31,7 +31,7 @@ func NormalizeIdentity(identity types.Identity) (types.Identity, error) {
31 return normalized, nil
32 }
33
34 -func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, error) {
34 +func NormalizeRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, error) {
35 desc.Address = strings.TrimSpace(desc.Address)
36 desc.Version = strings.TrimSpace(desc.Version)
37 desc.APIHTTPSAddr = strings.TrimSpace(desc.APIHTTPSAddr)
@@ -47,7 +47,7 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
47 }
48
49 if desc.APIHTTPSAddr != "" {
50 - normalized, err := NormalizeRelayURL(desc.APIHTTPSAddr)
50 + normalized, err := utils.NormalizeRelayURL(desc.APIHTTPSAddr)
51 if err != nil {
52 return types.RelayDescriptor{}, fmt.Errorf("normalize api https addr: %w", err)
53 }
@@ -98,7 +98,7 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
98 }
99
100 func RelayWireGuardEndpoint(desc types.RelayDescriptor) (string, error) {
101 - host := PortalRootHost(desc.APIHTTPSAddr)
101 + host := utils.PortalRootHost(desc.APIHTTPSAddr)
102 if host == "" {
103 return "", errors.New("api_https_addr host is required")
104 }
@@ -121,7 +121,7 @@ func ResolveRelayStateDir(path string) string {
121 }
122 }
123
124 -func ResolveRelayIdentityPath(path string) string {
124 +func resolveRelayIdentityPath(path string) string {
125 stateDir := ResolveRelayStateDir(path)
126 if stateDir == "" {
127 return ""
@@ -137,12 +137,13 @@ func ResolveRelayAdminSettingsPath(path string) string {
137 return filepath.Join(stateDir, types.RelayAdminSettingsFilename)
138 }
139
140 -func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
140 +func normalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
141 normalized := identity.Copy()
142 normalized.Name = strings.TrimSpace(normalized.Name)
143 normalized.Address = strings.TrimSpace(normalized.Address)
144 normalized.PublicKey = strings.TrimSpace(normalized.PublicKey)
145 normalized.PrivateKey = strings.TrimSpace(normalized.PrivateKey)
146 + normalized.TokenSecret = strings.TrimSpace(normalized.TokenSecret)
147
148 switch {
149 case normalized.PrivateKey != "":
@@ -150,7 +151,7 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
151 if err != nil {
152 return types.Identity{}, err
153 }
153 - if normalized.PublicKey != "" && !strings.EqualFold(TrimHexPrefix(normalized.PublicKey), resolved.PublicKey) {
154 + if normalized.PublicKey != "" && !strings.EqualFold(utils.TrimHexPrefix(normalized.PublicKey), resolved.PublicKey) {
155 return types.Identity{}, errors.New("identity public key does not match private key")
156 }
157 if normalized.Address != "" && !strings.EqualFold(normalized.Address, resolved.Address) {
@@ -164,7 +165,7 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
165 if err != nil {
166 return types.Identity{}, err
167 }
167 - normalized.PublicKey = strings.ToLower(TrimHexPrefix(normalized.PublicKey))
168 + normalized.PublicKey = strings.ToLower(utils.TrimHexPrefix(normalized.PublicKey))
169 if normalized.Address == "" {
170 normalized.Address = address
171 break
@@ -183,9 +184,9 @@ func NormalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
184 return normalized, nil
185 }
186
186 -func NormalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIdentity, error) {
187 +func normalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIdentity, error) {
188 normalized := identity.Copy()
188 - baseIdentity, err := NormalizeStoredIdentity(normalized.Identity)
189 + baseIdentity, err := normalizeStoredIdentity(normalized.Identity)
190 if err != nil {
191 return types.RelayIdentity{}, err
192 }
@@ -225,10 +226,11 @@ func NormalizeStoredRelayIdentity(identity types.RelayIdentity) (types.RelayIden
226 }
227
228 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"`
229 + Name string `json:"name,omitempty"`
230 + Address string `json:"address,omitempty"`
231 + PublicKey string `json:"public_key,omitempty"`
232 + PrivateKey string `json:"private_key,omitempty"`
233 + TokenSecret string `json:"token_secret,omitempty"`
234 }
235
236 type storedRelayIdentity struct {
@@ -239,41 +241,52 @@ type storedRelayIdentity struct {
241 EncryptedClientHelloSeed string `json:"encrypted_client_hello_seed,omitempty"`
242 }
243
242 -func SaveIdentity(path string, identity types.Identity) error {
244 +func saveIdentity(path string, identity types.Identity) error {
245 path = strings.TrimSpace(path)
246 if path == "" {
247 return errors.New("identity path is required")
248 }
247 - normalized, err := NormalizeStoredIdentity(identity)
249 + normalized, err := normalizeStoredIdentity(identity)
250 if err != nil {
251 return err
252 }
251 - if err := WriteJSONFile(path, storedIdentity{
252 - Name: normalized.Name,
253 - Address: normalized.Address,
254 - PublicKey: normalized.PublicKey,
255 - PrivateKey: normalized.PrivateKey,
253 + normalized, err = ensureTokenSecret(normalized)
254 + if err != nil {
255 + return err
256 + }
257 + if err := utils.WriteJSONFile(path, storedIdentity{
258 + Name: normalized.Name,
259 + Address: normalized.Address,
260 + PublicKey: normalized.PublicKey,
261 + PrivateKey: normalized.PrivateKey,
262 + TokenSecret: normalized.TokenSecret,
263 }, 0o600); err != nil {
264 return fmt.Errorf("write identity file: %w", err)
265 }
266 return nil
267 }
268
262 -func SaveRelayIdentity(path string, identity types.RelayIdentity) error {
263 - path = ResolveRelayIdentityPath(path)
269 +func saveRelayIdentity(path string, identity types.RelayIdentity) error {
270 + path = resolveRelayIdentityPath(path)
271 if path == "" {
272 return errors.New("identity path is required")
273 }
267 - normalized, err := NormalizeStoredRelayIdentity(identity)
274 + normalized, err := normalizeStoredRelayIdentity(identity)
275 + if err != nil {
276 + return err
277 + }
278 + baseIdentity, err := ensureTokenSecret(normalized.Identity)
279 if err != nil {
280 return err
281 }
271 - if err := WriteJSONFile(path, storedRelayIdentity{
282 + normalized.Identity = baseIdentity
283 + if err := utils.WriteJSONFile(path, storedRelayIdentity{
284 storedIdentity: storedIdentity{
273 - Name: normalized.Name,
274 - Address: normalized.Address,
275 - PublicKey: normalized.PublicKey,
276 - PrivateKey: normalized.PrivateKey,
285 + Name: normalized.Name,
286 + Address: normalized.Address,
287 + PublicKey: normalized.PublicKey,
288 + PrivateKey: normalized.PrivateKey,
289 + TokenSecret: normalized.TokenSecret,
290 },
291 AdminSecretKey: normalized.AdminSecretKey,
292 WireGuardPublicKey: normalized.WireGuardPublicKey,
@@ -285,38 +298,40 @@ func SaveRelayIdentity(path string, identity types.RelayIdentity) error {
298 return nil
299 }
300
288 -func LoadIdentity(path string) (types.Identity, error) {
301 +func loadIdentity(path string) (types.Identity, error) {
302 path = strings.TrimSpace(path)
303 if path == "" {
304 return types.Identity{}, errors.New("identity path is required")
305 }
306 var payload storedIdentity
294 - if err := ReadJSONFile(path, &payload); err != nil {
307 + if err := utils.ReadJSONFile(path, &payload); err != nil {
308 return types.Identity{}, fmt.Errorf("read identity file: %w", err)
309 }
297 - return NormalizeStoredIdentity(types.Identity{
298 - Name: payload.Name,
299 - Address: payload.Address,
300 - PublicKey: payload.PublicKey,
301 - PrivateKey: payload.PrivateKey,
310 + return normalizeStoredIdentity(types.Identity{
311 + Name: payload.Name,
312 + Address: payload.Address,
313 + PublicKey: payload.PublicKey,
314 + PrivateKey: payload.PrivateKey,
315 + TokenSecret: payload.TokenSecret,
316 })
317 }
318
305 -func LoadRelayIdentity(path string) (types.RelayIdentity, error) {
306 - path = ResolveRelayIdentityPath(path)
319 +func loadRelayIdentity(path string) (types.RelayIdentity, error) {
320 + path = resolveRelayIdentityPath(path)
321 if path == "" {
322 return types.RelayIdentity{}, errors.New("identity path is required")
323 }
324 var payload storedRelayIdentity
311 - if err := ReadJSONFile(path, &payload); err != nil {
325 + if err := utils.ReadJSONFile(path, &payload); err != nil {
326 return types.RelayIdentity{}, fmt.Errorf("read identity file: %w", err)
327 }
314 - return NormalizeStoredRelayIdentity(types.RelayIdentity{
328 + return normalizeStoredRelayIdentity(types.RelayIdentity{
329 Identity: types.Identity{
316 - Name: payload.Name,
317 - Address: payload.Address,
318 - PublicKey: payload.PublicKey,
319 - PrivateKey: payload.PrivateKey,
330 + Name: payload.Name,
331 + Address: payload.Address,
332 + PublicKey: payload.PublicKey,
333 + PrivateKey: payload.PrivateKey,
334 + TokenSecret: payload.TokenSecret,
335 },
336 AdminSecretKey: payload.AdminSecretKey,
337 WireGuardPublicKey: payload.WireGuardPublicKey,
@@ -325,7 +340,7 @@ func LoadRelayIdentity(path string) (types.RelayIdentity, error) {
340 })
341 }
342
328 -func ParseIdentityJSON(raw string) (types.Identity, error) {
343 +func parseIdentityJSON(raw string) (types.Identity, error) {
344 raw = strings.TrimSpace(raw)
345 if raw == "" {
346 return types.Identity{}, errors.New("identity json is required")
@@ -335,21 +350,22 @@ func ParseIdentityJSON(raw string) (types.Identity, error) {
350 if err := json.Unmarshal([]byte(raw), &payload); err != nil {
351 return types.Identity{}, fmt.Errorf("decode identity json: %w", err)
352 }
338 - return NormalizeStoredIdentity(types.Identity{
339 - Name: payload.Name,
340 - Address: payload.Address,
341 - PublicKey: payload.PublicKey,
342 - PrivateKey: payload.PrivateKey,
353 + return normalizeStoredIdentity(types.Identity{
354 + Name: payload.Name,
355 + Address: payload.Address,
356 + PublicKey: payload.PublicKey,
357 + PrivateKey: payload.PrivateKey,
358 + TokenSecret: payload.TokenSecret,
359 })
360 }
361
346 -func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity, bool, error) {
362 +func loadOrCreateIdentity(path string, identity types.Identity) (types.Identity, bool, error) {
363 path = strings.TrimSpace(path)
364 if path == "" {
365 return types.Identity{}, false, errors.New("identity path is required")
366 }
367
352 - stored, err := LoadIdentity(path)
368 + stored, err := loadIdentity(path)
369 switch {
370 case err == nil:
371 if name := strings.TrimSpace(identity.Name); name != "" {
@@ -364,13 +380,16 @@ func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
380 if privateKey := strings.TrimSpace(identity.PrivateKey); privateKey != "" {
381 stored.PrivateKey = privateKey
382 }
383 + if tokenSecret := strings.TrimSpace(identity.TokenSecret); tokenSecret != "" {
384 + stored.TokenSecret = tokenSecret
385 + }
386 if strings.TrimSpace(stored.PrivateKey) == "" {
387 return types.Identity{}, false, errors.New("stored identity private key is required")
388 }
370 - if err := SaveIdentity(path, stored); err != nil {
389 + if err := saveIdentity(path, stored); err != nil {
390 return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
391 }
373 - loaded, err := LoadIdentity(path)
392 + loaded, err := loadIdentity(path)
393 if err != nil {
394 return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
395 }
@@ -391,29 +410,77 @@ func LoadOrCreateIdentity(path string, identity types.Identity) (types.Identity,
410 created.PublicKey = generated.PublicKey
411 }
412 created.PrivateKey = generated.PrivateKey
394 - if err := SaveIdentity(path, created); err != nil {
413 + if strings.TrimSpace(created.TokenSecret) == "" {
414 + created, err = ensureTokenSecret(created)
415 + if err != nil {
416 + return types.Identity{}, false, err
417 + }
418 + }
419 + if err := saveIdentity(path, created); err != nil {
420 return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
421 }
397 - loaded, err := LoadIdentity(path)
422 + loaded, err := loadIdentity(path)
423 if err != nil {
424 return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
425 }
426 return loaded, true, nil
427 }
428
429 +func ResolveListenerIdentity(baseIdentity types.Identity, target, identityPath, identityJSON string) (types.Identity, bool, error) {
430 + identityPath = strings.TrimSpace(identityPath)
431 + identityJSON = strings.TrimSpace(identityJSON)
432 + resolvedName, err := resolveExposeName(baseIdentity.Name, target, identityPath, identityJSON)
433 + if err != nil {
434 + return types.Identity{}, false, err
435 + }
436 + baseIdentity.Name = resolvedName
437 + if identityJSON != "" {
438 + provided, err := parseIdentityJSON(identityJSON)
439 + if err != nil {
440 + return types.Identity{}, false, err
441 + }
442 + provided.Name = baseIdentity.Name
443 + if identityPath != "" {
444 + if err := saveIdentity(identityPath, provided); err != nil {
445 + return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
446 + }
447 + provided, err = loadIdentity(identityPath)
448 + if err != nil {
449 + return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
450 + }
451 + }
452 + resolved, err := resolveLeaseIdentity(provided)
453 + return resolved, false, err
454 + }
455 + if identityPath == "" {
456 + resolved, err := resolveLeaseIdentity(baseIdentity)
457 + return resolved, false, err
458 + }
459 +
460 + loaded, created, err := loadOrCreateIdentity(identityPath, baseIdentity)
461 + if err != nil {
462 + return types.Identity{}, false, err
463 + }
464 + resolved, err := resolveLeaseIdentity(loaded)
465 + if err != nil {
466 + return types.Identity{}, false, err
467 + }
468 + return resolved, created, nil
469 +}
470 +
471 func LoadOrCreateRelayIdentity(path, rootHost string, discoveryEnabled bool) (types.RelayIdentity, error) {
405 - path = ResolveRelayIdentityPath(path)
472 + path = resolveRelayIdentityPath(path)
473 if path == "" {
474 return types.RelayIdentity{}, errors.New("identity path is required")
475 }
476 rootHost = strings.TrimSpace(rootHost)
410 - if normalizedRootHost := PortalRootHost(rootHost); normalizedRootHost != "" {
477 + if normalizedRootHost := utils.PortalRootHost(rootHost); normalizedRootHost != "" {
478 rootHost = normalizedRootHost
479 } else {
413 - rootHost = NormalizeHostname(rootHost)
480 + rootHost = utils.NormalizeHostname(rootHost)
481 }
482
416 - stored, err := LoadRelayIdentity(path)
483 + stored, err := loadRelayIdentity(path)
484 switch {
485 case err == nil:
486 if rootHost != "" {
@@ -423,10 +490,10 @@ func LoadOrCreateRelayIdentity(path, rootHost string, discoveryEnabled bool) (ty
490 if err := populateRelayIdentity(&stored, discoveryEnabled); err != nil {
491 return types.RelayIdentity{}, err
492 }
426 - if err := SaveRelayIdentity(path, stored); err != nil {
493 + if err := saveRelayIdentity(path, stored); err != nil {
494 return types.RelayIdentity{}, fmt.Errorf("persist identity: %w", err)
495 }
429 - loaded, err := LoadRelayIdentity(path)
496 + loaded, err := loadRelayIdentity(path)
497 if err != nil {
498 return types.RelayIdentity{}, fmt.Errorf("load identity: %w", err)
499 }
@@ -449,14 +516,18 @@ func LoadOrCreateRelayIdentity(path, rootHost string, discoveryEnabled bool) (ty
516 created.PublicKey = generated.PublicKey
517 }
518 created.PrivateKey = generated.PrivateKey
519 + created.Identity, err = ensureTokenSecret(created.Identity)
520 + if err != nil {
521 + return types.RelayIdentity{}, err
522 + }
523
524 if err := populateRelayIdentity(&created, discoveryEnabled); err != nil {
525 return types.RelayIdentity{}, err
526 }
456 - if err := SaveRelayIdentity(path, created); err != nil {
527 + if err := saveRelayIdentity(path, created); err != nil {
528 return types.RelayIdentity{}, fmt.Errorf("persist identity: %w", err)
529 }
459 - loaded, err := LoadRelayIdentity(path)
530 + loaded, err := loadRelayIdentity(path)
531 if err != nil {
532 return types.RelayIdentity{}, fmt.Errorf("load identity: %w", err)
533 }
@@ -467,9 +538,14 @@ func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool)
538 if identity == nil {
539 return errors.New("relay identity is required")
540 }
541 + baseIdentity, err := ensureTokenSecret(identity.Identity)
542 + if err != nil {
543 + return err
544 + }
545 + identity.Identity = baseIdentity
546
547 if strings.TrimSpace(identity.AdminSecretKey) == "" {
472 - adminSecretKey, err := identity.DeriveToken("admin-secret")
548 + adminSecretKey, err := DeriveToken(identity.Identity, "admin-secret")
549 if err != nil {
550 return fmt.Errorf("derive relay admin secret key: %w", err)
551 }
@@ -486,55 +562,13 @@ func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool)
562 }
563
564 if strings.TrimSpace(identity.EncryptedClientHelloSeed) == "" {
489 - identity.EncryptedClientHelloSeed = RandomID("")
565 + identity.EncryptedClientHelloSeed = utils.RandomID("")
566 }
567
568 return nil
569 }
570
495 -func ResolveListenerIdentity(identity types.Identity, target, identityPath, identityJSON string) (types.Identity, bool, error) {
496 - identityPath = strings.TrimSpace(identityPath)
497 - identityJSON = strings.TrimSpace(identityJSON)
498 - resolvedName, err := resolveExposeName(identity.Name, target, identityPath, identityJSON)
499 - if err != nil {
500 - return types.Identity{}, false, err
501 - }
502 - identity.Name = resolvedName
503 - if identityJSON != "" {
504 - provided, err := ParseIdentityJSON(identityJSON)
505 - if err != nil {
506 - return types.Identity{}, false, err
507 - }
508 - provided.Name = identity.Name
509 - if identityPath != "" {
510 - if err := SaveIdentity(identityPath, provided); err != nil {
511 - return types.Identity{}, false, fmt.Errorf("persist identity: %w", err)
512 - }
513 - provided, err = LoadIdentity(identityPath)
514 - if err != nil {
515 - return types.Identity{}, false, fmt.Errorf("load identity: %w", err)
516 - }
517 - }
518 - resolved, err := ResolveLeaseIdentity(provided)
519 - return resolved, false, err
520 - }
521 - if identityPath == "" {
522 - resolved, err := ResolveLeaseIdentity(identity)
523 - return resolved, false, err
524 - }
525 -
526 - loaded, created, err := LoadOrCreateIdentity(identityPath, identity)
527 - if err != nil {
528 - return types.Identity{}, false, err
529 - }
530 - resolved, err := ResolveLeaseIdentity(loaded)
531 - if err != nil {
532 - return types.Identity{}, false, err
533 - }
534 - return resolved, created, nil
535 -}
536 -
537 -func NormalizeIdentityKey(raw string) string {
571 +func normalizeIdentityKey(raw string) string {
572 key := strings.ToLower(strings.TrimSpace(raw))
573 if key == "" {
574 return ""
@@ -547,7 +581,7 @@ func NormalizeIdentityKey(raw string) string {
581 }
582
583 func NormalizeIdentityKeys(inputs []string) []string {
550 - return normalizeUniqueStrings(inputs, NormalizeIdentityKey)
584 + return normalizeUniqueStrings(inputs, normalizeIdentityKey)
585 }
586
587 func NormalizeIdentityKeyBPS(inputs map[string]int64) map[string]int64 {
@@ -557,7 +591,7 @@ func NormalizeIdentityKeyBPS(inputs map[string]int64) map[string]int64 {
591
592 out := make(map[string]int64, len(inputs))
593 for input, bps := range inputs {
560 - key := NormalizeIdentityKey(input)
594 + key := normalizeIdentityKey(input)
595 if key == "" || bps <= 0 {
596 continue
597 }
@@ -569,10 +603,38 @@ func NormalizeIdentityKeyBPS(inputs map[string]int64) map[string]int64 {
603 return out
604 }
605
572 -func ResolveLeaseIdentity(identity types.Identity) (types.Identity, error) {
606 +func resolveExposeName(name, target, identityPath, identityJSON string) (string, error) {
607 + if name = strings.TrimSpace(name); name != "" {
608 + return name, nil
609 + }
610 + if identityJSON = strings.TrimSpace(identityJSON); identityJSON != "" {
611 + providedIdentity, err := parseIdentityJSON(identityJSON)
612 + if err != nil {
613 + return "", err
614 + }
615 + if name := strings.TrimSpace(providedIdentity.Name); name != "" {
616 + return name, nil
617 + }
618 + }
619 + if identityPath = strings.TrimSpace(identityPath); identityPath != "" {
620 + storedIdentity, err := loadIdentity(identityPath)
621 + switch {
622 + case err == nil:
623 + if name := strings.TrimSpace(storedIdentity.Name); name != "" {
624 + return name, nil
625 + }
626 + case !errors.Is(err, os.ErrNotExist):
627 + return "", err
628 + }
629 + }
630 +
631 + return utils.DefaultExposeName(target, utils.RandomID("cli_"))
632 +}
633 +
634 +func resolveLeaseIdentity(identity types.Identity) (types.Identity, error) {
635 resolved := identity.Copy()
636
575 - name, err := NormalizeDNSLabel(resolved.Name)
637 + name, err := utils.NormalizeDNSLabel(resolved.Name)
638 if err != nil {
639 return types.Identity{}, err
640 }
@@ -597,154 +659,29 @@ func ResolveLeaseIdentity(identity types.Identity) (types.Identity, error) {
659
660 resolved.PublicKey = signingIdentity.PublicKey
661 resolved.PrivateKey = signingIdentity.PrivateKey
600 - return resolved, nil
601 -}
602 -
603 -var exposeNameOpeners = []string{
604 - "arcade", "bouncy", "bravo", "bubble", "candy", "cosmic", "dapper", "electric",
605 - "fancy", "fizzy", "flashy", "fuzzy", "gentle", "glitter", "golden", "happy",
606 - "hyper", "jazzy", "jolly", "lively", "lucky", "magic", "mellow", "minty",
607 - "misty", "moonlit", "mystic", "neon", "nova", "peppy", "pixel", "playful",
608 - "poppy", "rapid", "rocket", "rowdy", "snappy", "snazzy", "sparkly", "spicy",
609 - "sprightly", "starry", "sunny", "swift", "tangy", "tidy", "toasty", "turbo",
610 - "velvet", "vivid", "wavy", "whimsy", "wild", "wonky", "zany", "zesty",
611 -}
612 -
613 -var exposeNameCenters = []string{
614 - "alpaca", "badger", "banjo", "beacon", "biscuit", "capybara", "comet", "cricket",
615 - "dragon", "falcon", "feather", "fjord", "fox", "gadget", "gecko", "gizmo",
616 - "harbor", "heron", "iguana", "jelly", "koala", "lemur", "mango", "narwhal",
617 - "nebula", "noodle", "octopus", "otter", "panda", "pepper", "phoenix", "pickle",
618 - "puffin", "quokka", "radar", "ranger", "rocket", "scooter", "seahorse", "skylark",
619 - "sprocket", "starling", "sunbeam", "taco", "thimble", "tiger", "toucan", "triton",
620 - "walrus", "widget", "willow", "wombat", "yeti", "zeppelin", "zigzag", "zinnia",
621 -}
622 -
623 -var exposeNameClosers = []string{
624 - "arcade", "beacon", "boogie", "bounce", "burst", "cascade", "chorus", "dash",
625 - "disco", "drift", "echo", "fiesta", "flare", "flash", "flight", "flip",
626 - "glow", "groove", "jam", "jive", "launch", "loop", "march", "orbit",
627 - "parade", "party", "pulse", "quest", "rally", "riot", "ripple", "rodeo",
628 - "roll", "rush", "serenade", "shuffle", "signal", "sketch", "spark", "sprint",
629 - "starlight", "stride", "sway", "swoop", "twirl", "uplift", "vibe", "voyage",
630 - "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
631 -}
632 -
633 -const (
634 - defaultExposeTargetPort = "3000"
635 - defaultExposeTargetHost = "127.0.0.1"
636 -)
637 -
638 -// DefaultExposeName generates a deterministic 3-word DNS label from a target
639 -// address and seed using FNV-1a hashing. The algorithm matches the frontend
640 -// implementation in frontend/src/lib/exposeName.ts:buildDefaultExposeName.
641 -func DefaultExposeName(target, rawSeed string) (string, error) {
642 - seed := strings.TrimSpace(rawSeed)
643 - if cut, ok := strings.CutPrefix(seed, "cli_"); ok {
644 - seed = cut
645 - }
646 - if seed == "" {
647 - seed = "portal"
648 - }
649 -
650 - input := []byte(seed + "|" + normalizeExposeTarget(target))
651 - first := fnv1a32(input, 0x811c9dc5)
652 - second := fnv1a32(input, 0x9e3779b9)
653 - third := fnv1a32(input, 0x85ebca6b)
654 -
655 - label := strings.Join([]string{
656 - exposeNameOpeners[int(first&0xff)%len(exposeNameOpeners)],
657 - exposeNameCenters[int(second&0xff)%len(exposeNameCenters)],
658 - exposeNameClosers[int(third&0xff)%len(exposeNameClosers)],
659 - }, "-")
660 -
661 - return NormalizeDNSLabel(label)
662 + return ensureTokenSecret(resolved)
663 }
664
664 -// normalizeExposeTarget normalizes a target address for deterministic name
665 -// generation. Must match frontend/src/lib/exposeName.ts:normalizeExposeTarget.
666 -func normalizeExposeTarget(raw string) string {
667 - trimmed := strings.TrimSpace(raw)
668 - candidate := trimmed
669 - if candidate == "" {
670 - candidate = defaultExposeTargetPort
671 - }
672 -
673 - if isAllDigits(candidate) {
674 - return defaultExposeTargetHost + ":" + candidate
675 - }
676 -
677 - if strings.Contains(candidate, "://") {
678 - u, err := url.Parse(candidate)
679 - if err != nil {
680 - return candidate
681 - }
682 - if (u.Scheme == "http" || u.Scheme == "https") &&
683 - u.Host != "" &&
684 - (u.Path == "" || u.Path == "/") &&
685 - u.RawQuery == "" &&
686 - u.Fragment == "" {
687 - return u.Host
688 - }
689 - return candidate
690 - }
691 -
692 - u, err := url.Parse("tcp://" + candidate)
693 - if err != nil || u.Hostname() == "" {
694 - return candidate
695 - }
696 - port := u.Port()
697 - if port == "" {
698 - port = "80"
699 - }
700 - return net.JoinHostPort(u.Hostname(), port)
701 -}
702 -
703 -func isAllDigits(s string) bool {
704 - if s == "" {
705 - return false
706 - }
707 - for _, c := range s {
708 - if c < '0' || c > '9' {
709 - return false
710 - }
665 +func normalizeUniqueStrings(inputs []string, normalize func(string) string) []string {
666 + if len(inputs) == 0 {
667 + return nil
668 }
712 - return true
713 -}
669
715 -func resolveExposeName(name, target, identityPath, identityJSON string) (string, error) {
716 - if name = strings.TrimSpace(name); name != "" {
717 - return name, nil
718 - }
719 - if identityJSON = strings.TrimSpace(identityJSON); identityJSON != "" {
720 - identity, err := ParseIdentityJSON(identityJSON)
721 - if err != nil {
722 - return "", err
723 - }
724 - if name := strings.TrimSpace(identity.Name); name != "" {
725 - return name, nil
670 + out := make([]string, 0, len(inputs))
671 + seen := make(map[string]struct{}, len(inputs))
672 + for _, input := range inputs {
673 + normalized := normalize(input)
674 + if normalized == "" {
675 + continue
676 }
727 - }
728 - if identityPath = strings.TrimSpace(identityPath); identityPath != "" {
729 - identity, err := LoadIdentity(identityPath)
730 - switch {
731 - case err == nil:
732 - if name := strings.TrimSpace(identity.Name); name != "" {
733 - return name, nil
734 - }
735 - case !errors.Is(err, os.ErrNotExist):
736 - return "", err
677 + if _, ok := seen[normalized]; ok {
678 + continue
679 }
680 + seen[normalized] = struct{}{}
681 + out = append(out, normalized)
682 }
739 -
740 - return DefaultExposeName(target, RandomID("cli_"))
741 -}
742 -
743 -func fnv1a32(data []byte, seed uint32) uint32 {
744 - h := seed
745 - for _, b := range data {
746 - h ^= uint32(b)
747 - h *= 0x01000193
683 + if len(out) == 0 {
684 + return nil
685 }
749 - return h
686 + return out
687 }
portal/identity/token.go new
+50
@@ -0,0 +1,50 @@
1 +package identity
2 +
3 +import (
4 + "crypto/hmac"
5 + "crypto/rand"
6 + "crypto/sha256"
7 + "encoding/base64"
8 + "errors"
9 + "fmt"
10 + "strconv"
11 + "strings"
12 +
13 + "github.com/gosuda/portal-tunnel/v2/types"
14 +)
15 +
16 +func ensureTokenSecret(identity types.Identity) (types.Identity, error) {
17 + identity = identity.Copy()
18 + identity.TokenSecret = strings.TrimSpace(identity.TokenSecret)
19 + if identity.TokenSecret != "" {
20 + return identity, nil
21 + }
22 +
23 + var secret [32]byte
24 + if _, err := rand.Read(secret[:]); err != nil {
25 + return types.Identity{}, fmt.Errorf("generate token secret: %w", err)
26 + }
27 + identity.TokenSecret = base64.RawURLEncoding.EncodeToString(secret[:])
28 + return identity, nil
29 +}
30 +
31 +// DeriveToken derives a deterministic identity-scoped token from ordered
32 +// length-prefixed token parts. The first part should identify the token family.
33 +func DeriveToken(identity types.Identity, parts ...string) (string, error) {
34 + tokenSecret := strings.TrimSpace(identity.TokenSecret)
35 + if tokenSecret == "" {
36 + return "", errors.New("identity token secret is required")
37 + }
38 +
39 + mac := hmac.New(sha256.New, []byte(tokenSecret))
40 + _, _ = mac.Write([]byte("Portal identity token v1\n"))
41 + _, _ = mac.Write([]byte(identity.Key()))
42 + for _, part := range parts {
43 + part = strings.TrimSpace(part)
44 + _, _ = mac.Write([]byte("\n"))
45 + _, _ = mac.Write([]byte(strconv.Itoa(len(part))))
46 + _, _ = mac.Write([]byte(":"))
47 + _, _ = mac.Write([]byte(part))
48 + }
49 + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
50 +}
portal/identity/wireguard.go new
+112
@@ -0,0 +1,112 @@
1 +package identity
2 +
3 +import (
4 + "crypto/rand"
5 + "crypto/sha256"
6 + "encoding/base64"
7 + "encoding/hex"
8 + "errors"
9 + "net/netip"
10 + "strings"
11 +
12 + "golang.org/x/crypto/curve25519"
13 +)
14 +
15 +func NormalizeWireGuardPrivateKey(raw string) (string, error) {
16 + key, err := decodeKey(raw)
17 + if err != nil {
18 + return "", err
19 + }
20 + clampPrivateKey(&key)
21 + return base64.StdEncoding.EncodeToString(key[:]), nil
22 +}
23 +
24 +func GenerateWireGuardPrivateKey() (string, error) {
25 + var key [32]byte
26 + if _, err := rand.Read(key[:]); err != nil {
27 + return "", err
28 + }
29 + clampPrivateKey(&key)
30 + return base64.StdEncoding.EncodeToString(key[:]), nil
31 +}
32 +
33 +func WireGuardPublicKeyFromPrivate(raw string) (string, error) {
34 + privateKey, err := decodeKey(raw)
35 + if err != nil {
36 + return "", err
37 + }
38 + clampPrivateKey(&privateKey)
39 + var publicKey [32]byte
40 + curve25519.ScalarBaseMult(&publicKey, &privateKey)
41 + return base64.StdEncoding.EncodeToString(publicKey[:]), nil
42 +}
43 +
44 +func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
45 + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(publicKey))
46 + if err != nil {
47 + return "", errors.New("wireguard public key must be base64 encoded")
48 + }
49 + if len(decoded) != 32 {
50 + return "", errors.New("wireguard public key must be 32 bytes")
51 + }
52 +
53 + sum := sha256.Sum256(decoded)
54 + return netip.AddrFrom4([4]byte{
55 + 100,
56 + 64 + (sum[0] & 0x3f),
57 + sum[1],
58 + 1 + (sum[2] % 254),
59 + }).String(), nil
60 +}
61 +
62 +func WireGuardKeyHex(raw string) (string, error) {
63 + key, err := decodeKey(raw)
64 + if err != nil {
65 + return "", err
66 + }
67 + return hex.EncodeToString(key[:]), nil
68 +}
69 +
70 +func ValidateWireGuardPublicKey(raw string) error {
71 + key := strings.TrimSpace(raw)
72 + if key == "" {
73 + return errors.New("wireguard_public_key is required")
74 + }
75 + decoded, err := base64.StdEncoding.DecodeString(key)
76 + if err != nil {
77 + return errors.New("wireguard_public_key must be base64 encoded")
78 + }
79 + if len(decoded) != 32 {
80 + return errors.New("wireguard_public_key must be 32 bytes")
81 + }
82 + return nil
83 +}
84 +
85 +func decodeKey(raw string) ([32]byte, error) {
86 + var key [32]byte
87 + value := strings.TrimSpace(raw)
88 + if value == "" {
89 + return key, errors.New("wireguard key is required")
90 + }
91 +
92 + var decoded []byte
93 + var err error
94 + if len(value) == 64 && !strings.Contains(value, "=") {
95 + decoded, err = hex.DecodeString(value)
96 + } else {
97 + decoded, err = base64.StdEncoding.DecodeString(value)
98 + }
99 + if err != nil {
100 + return key, errors.New("wireguard key must be base64 or hex encoded")
101 + }
102 + if len(decoded) != len(key) {
103 + return key, errors.New("wireguard key must be 32 bytes")
104 + }
105 + copy(key[:], decoded)
106 + return key, nil
107 +}
108 +
109 +func clampPrivateKey(key *[32]byte) {
110 + key[0] &= 248
111 + key[31] = (key[31] & 127) | 64
112 +}
portal/lease.go
+46 -42
@@ -15,6 +15,7 @@ import (
15
16 "github.com/gosuda/portal-tunnel/v2/portal/acme"
17 "github.com/gosuda/portal-tunnel/v2/portal/auth"
18 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
20 "github.com/gosuda/portal-tunnel/v2/portal/policy"
21 "github.com/gosuda/portal-tunnel/v2/portal/transport"
@@ -32,38 +33,41 @@ const (
33 )
34
35 type leaseRegistry struct {
35 - records []*leaseRecord
36 - rootHostname string
37 - sniPort int
38 - tokenPrivateKey string
39 - tokenPublicKey string
40 - tokenKeyID string
41 - tokenIssuer string
42 - policy *policy.Runtime
43 - udpPorts *transport.PortAllocator
44 - tcpPorts *transport.PortAllocator
45 - proxy *proxy
46 - mu sync.RWMutex
36 + records []*leaseRecord
37 + rootHostname string
38 + sniPort int
39 + tokenAuthority identity.Authority
40 + tokenIssuer string
41 + policy *policy.Runtime
42 + udpPorts *transport.PortAllocator
43 + tcpPorts *transport.PortAllocator
44 + proxy *proxy
45 + mu sync.RWMutex
46 }
47
49 -func newLeaseRegistry(udpEnabled, tcpPortEnabled bool, minPort, maxPort int, rootHostname string, sniPort int, tokenPrivateKey, tokenPublicKey, tokenKeyID, tokenIssuer string, trustProxyHeaders bool, rawTrustedProxyCIDRs string) (*leaseRegistry, error) {
48 +func newLeaseRegistry(udpEnabled, tcpPortEnabled bool, minPort, maxPort int, rootHostname string, sniPort int, tokenAuthority identity.Authority, tokenIssuer string, trustProxyHeaders bool, rawTrustedProxyCIDRs string) (*leaseRegistry, error) {
49 + if tokenAuthority == nil {
50 + return nil, errors.New("lease token authority is required")
51 + }
52 + tokenIdentity := tokenAuthority.Identity()
53 + if strings.TrimSpace(tokenIdentity.PublicKey) == "" {
54 + return nil, errors.New("lease token authority public key is required")
55 + }
56 runtime, err := policy.NewRuntime(udpEnabled, tcpPortEnabled, trustProxyHeaders, rawTrustedProxyCIDRs)
57 if err != nil {
58 return nil, err
59 }
60
61 return &leaseRegistry{
56 - records: make([]*leaseRecord, 0),
57 - rootHostname: utils.NormalizeHostname(rootHostname),
58 - sniPort: sniPort,
59 - tokenPrivateKey: tokenPrivateKey,
60 - tokenPublicKey: tokenPublicKey,
61 - tokenKeyID: tokenKeyID,
62 - tokenIssuer: tokenIssuer,
63 - policy: runtime,
64 - udpPorts: transport.NewPortAllocator(minPort, maxPort, defaultPortReservationGrace),
65 - tcpPorts: transport.NewPortAllocator(minPort, maxPort, defaultPortReservationGrace),
66 - proxy: &proxy{},
62 + records: make([]*leaseRecord, 0),
63 + rootHostname: utils.NormalizeHostname(rootHostname),
64 + sniPort: sniPort,
65 + tokenAuthority: tokenAuthority,
66 + tokenIssuer: tokenIssuer,
67 + policy: runtime,
68 + udpPorts: transport.NewPortAllocator(minPort, maxPort, defaultPortReservationGrace),
69 + tcpPorts: transport.NewPortAllocator(minPort, maxPort, defaultPortReservationGrace),
70 + proxy: &proxy{},
71 }, nil
72 }
73
@@ -150,7 +154,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
154 if r == nil {
155 return nil, types.RegisterResponse{}, errFeatureUnavailable
156 }
153 - identity, err := utils.NormalizeIdentity(req.Identity)
157 + leaseIdentity, err := identity.NormalizeIdentity(req.Identity)
158 if err != nil {
159 return nil, types.RegisterResponse{}, err
160 }
@@ -163,7 +167,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
167 ttl = time.Duration(req.TTL) * time.Second
168 }
169
166 - identityKey := identity.Key()
170 + identityKey := leaseIdentity.Key()
171 hopToken := strings.TrimSpace(req.HopToken)
172 routeHostname := utils.NormalizeHostname(req.RouteHostname)
173 hostnameHash := strings.TrimSpace(req.HostnameHash)
@@ -188,7 +192,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
192 return nil, types.RegisterResponse{}, errors.New("route hostname must be a child of relay root hostname")
193 }
194
191 - publicHostname, err = utils.LeaseHostname(identity.Name, r.rootHostname)
195 + publicHostname, err = utils.LeaseHostname(leaseIdentity.Name, r.rootHostname)
196 if err != nil {
197 return nil, types.RegisterResponse{}, err
198 }
@@ -222,13 +226,13 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
226
227 hostname := routeHostname
228 if hostname == "" && hopToken == "" {
225 - hostname, err = utils.LeaseHostname(identity.Name, r.rootHostname)
229 + hostname, err = utils.LeaseHostname(leaseIdentity.Name, r.rootHostname)
230 if err != nil {
231 return nil, types.RegisterResponse{}, err
232 }
233 }
234
231 - accessToken, claims, err := auth.IssueLeaseAccessToken(r.tokenPrivateKey, r.tokenKeyID, r.tokenIssuer, identity, ttl)
235 + accessToken, claims, err := auth.IssueLeaseAccessToken(r.tokenAuthority, r.tokenIssuer, leaseIdentity, ttl)
236 if err != nil {
237 return nil, types.RegisterResponse{}, err
238 }
@@ -237,7 +241,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
241
242 stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
243 record := &leaseRecord{
240 - Identity: identity,
244 + Identity: leaseIdentity,
245 Hostname: hostname,
246 HostnameHash: hostnameHash,
247 ECHConfigList: echConfigList,
@@ -256,7 +260,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
260 if r.udpPorts == nil {
261 return nil, types.RegisterResponse{}, errors.New("udp port allocation not available")
262 }
259 - port, err := r.udpPorts.Allocate(identity.Name)
263 + port, err := r.udpPorts.Allocate(leaseIdentity.Name)
264 if err != nil {
265 if errors.Is(err, transport.ErrPortExhausted) {
266 return nil, types.RegisterResponse{}, errUDPPortExhausted
@@ -272,7 +276,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
276 record.Close()
277 return nil, types.RegisterResponse{}, errors.New("tcp port allocation not available")
278 }
275 - port, err := r.tcpPorts.Allocate(identity.Name)
279 + port, err := r.tcpPorts.Allocate(leaseIdentity.Name)
280 if err != nil {
281 record.Close()
282 if errors.Is(err, transport.ErrPortExhausted) {
@@ -387,7 +391,7 @@ func (r *leaseRegistry) admitLeaseByToken(token string, requireDatagram bool) (*
391 return nil, errFeatureUnavailable
392 }
393 now := time.Now().UTC()
390 - claims, err := auth.VerifyLeaseAccessToken(token, r.tokenPublicKey, r.tokenIssuer, now)
394 + claims, err := auth.VerifyLeaseAccessToken(token, r.tokenAuthority.Identity().PublicKey, r.tokenIssuer, now)
395 if err != nil {
396 return nil, errUnauthorized
397 }
@@ -410,7 +414,7 @@ func (r *leaseRegistry) Renew(req types.RenewRequest, clientIP string) (types.Re
414 if r == nil {
415 return types.RenewResponse{}, errFeatureUnavailable
416 }
413 - claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, r.tokenPublicKey, r.tokenIssuer, time.Now().UTC())
417 + claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, r.tokenAuthority.Identity().PublicKey, r.tokenIssuer, time.Now().UTC())
418 if err != nil {
419 return types.RenewResponse{}, errUnauthorized
420 }
@@ -440,10 +444,10 @@ func (r *leaseRegistry) Renew(req types.RenewRequest, clientIP string) (types.Re
444 }
445 record.Metadata = req.Metadata.Copy()
446 r.policy.IPFilter().RegisterIdentityIP(leaseKey, clientIP)
443 - identity := record.Identity
447 + recordIdentity := record.Identity
448 r.mu.Unlock()
449
446 - nextAccessToken, _, err := auth.IssueLeaseAccessToken(r.tokenPrivateKey, r.tokenKeyID, r.tokenIssuer, identity, ttl)
450 + nextAccessToken, _, err := auth.IssueLeaseAccessToken(r.tokenAuthority, r.tokenIssuer, recordIdentity, ttl)
451 if err != nil {
452 return types.RenewResponse{}, &apiError{types.APIErrorCodeInternal, err.Error(), http.StatusInternalServerError}
453 }
@@ -458,7 +462,7 @@ func (r *leaseRegistry) Unregister(req types.UnregisterRequest) (*leaseRecord, e
462 if r == nil {
463 return nil, errFeatureUnavailable
464 }
461 - claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, r.tokenPublicKey, r.tokenIssuer, time.Now().UTC())
465 + claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, r.tokenAuthority.Identity().PublicKey, r.tokenIssuer, time.Now().UTC())
466 if err != nil {
467 return nil, errUnauthorized
468 }
@@ -483,7 +487,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
487 if route == nil {
488 return nil, errors.New("hop route is required")
489 }
486 - ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
490 + ownerKey, err := identity.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
491 if err != nil {
492 return nil, err
493 }
@@ -492,7 +496,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
496 echConfigList := bytes.Clone(route.ECHConfigList)
497 publicHostname := utils.NormalizeHostname(route.PublicHostname)
498 matchToken := route.MatchToken
495 - overlayIPv4, overlayErr := utils.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
499 + overlayIPv4, overlayErr := identity.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
500 forwardToken := route.ForwardToken
501 expiresAt := route.ExpiresAt.UTC()
502 hasPublicMatcher := routeHostname != "" || hostnameHash != ""
@@ -611,7 +615,7 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
615 if r == nil || route == nil {
616 return nil
617 }
614 - ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
618 + ownerKey, err := identity.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
619 if err != nil {
620 return nil
621 }
@@ -788,13 +792,13 @@ func (r *leaseRegistry) consumeVerifiedRegisterChallenge(req types.RegisterReque
792 }
793
794 func (r *leaseRegistry) issueLeaseAccessToken(record *leaseRecord, now time.Time) (string, error) {
791 - token, _, err := auth.IssueLeaseAccessToken(r.tokenPrivateKey, r.tokenKeyID, r.tokenIssuer, record.Identity, record.ExpiresAt.Sub(now))
795 + token, _, err := auth.IssueLeaseAccessToken(r.tokenAuthority, r.tokenIssuer, record.Identity, record.ExpiresAt.Sub(now))
796 return token, err
797 }
798
799 func (r *leaseRegistry) verifySigningAccessToken(token string) error {
800 now := time.Now().UTC()
797 - claims, err := auth.VerifyLeaseAccessToken(token, r.tokenPublicKey, r.tokenIssuer, time.Now().UTC())
801 + claims, err := auth.VerifyLeaseAccessToken(token, r.tokenAuthority.Identity().PublicKey, r.tokenIssuer, now)
802 if err != nil {
803 return errUnauthorized
804 }
portal/lease_test.go
+15 -10
@@ -8,6 +8,7 @@ import (
8 "testing"
9 "time"
10
11 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
12 "github.com/gosuda/portal-tunnel/v2/portal/policy"
13 "github.com/gosuda/portal-tunnel/v2/portal/transport"
14 "github.com/gosuda/portal-tunnel/v2/types"
@@ -16,11 +17,15 @@ import (
17
18 func newTestRegistry(t *testing.T) *leaseRegistry {
19 t.Helper()
19 - relay, err := utils.LoadOrCreateRelayIdentity(t.TempDir(), "example.com", false)
20 + relay, err := identity.LoadOrCreateRelayIdentity(t.TempDir(), "example.com", false)
21 if err != nil {
22 t.Fatalf("LoadOrCreateRelayIdentity() error = %v", err)
23 }
23 - registry, err := newLeaseRegistry(false, false, 10000, 10100, relay.Name, 443, relay.PrivateKey, relay.PublicKey, relay.Address, "https://example.com", false, "")
24 + relayAuthority, err := identity.NewLocalAuthority(relay.Identity)
25 + if err != nil {
26 + t.Fatalf("identity.NewLocalAuthority() error = %v", err)
27 + }
28 + registry, err := newLeaseRegistry(false, false, 10000, 10100, relay.Name, 443, relayAuthority, "https://example.com", false, "")
29 if err != nil {
30 t.Fatalf("newLeaseRegistry() error = %v", err)
31 }
@@ -29,12 +34,12 @@ func newTestRegistry(t *testing.T) *leaseRegistry {
34
35 func newTestLeaseIdentity(t *testing.T, name string) types.Identity {
36 t.Helper()
32 - identity, err := utils.ResolveSecp256k1Identity("")
37 + testIdentity, err := identity.ResolveSecp256k1Identity("")
38 if err != nil {
34 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
39 + t.Fatalf("identity.ResolveSecp256k1Identity() error = %v", err)
40 }
36 - identity.Name = name
37 - return identity
41 + testIdentity.Name = name
42 + return testIdentity
43 }
44
45 func TestLeaseRegistryLifecycle(t *testing.T) {
@@ -157,13 +162,13 @@ func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
162
163 registry := newTestRegistry(t)
164 owner := newTestLeaseIdentity(t, "multi-hop-owner")
160 - wgPrivate, err := utils.GenerateWireGuardPrivateKey()
165 + wgPrivate, err := identity.GenerateWireGuardPrivateKey()
166 if err != nil {
162 - t.Fatalf("GenerateWireGuardPrivateKey() error = %v", err)
167 + t.Fatalf("identity.GenerateWireGuardPrivateKey() error = %v", err)
168 }
164 - wgPublic, err := utils.WireGuardPublicKeyFromPrivate(wgPrivate)
169 + wgPublic, err := identity.WireGuardPublicKeyFromPrivate(wgPrivate)
170 if err != nil {
166 - t.Fatalf("WireGuardPublicKeyFromPrivate() error = %v", err)
171 + t.Fatalf("identity.WireGuardPublicKeyFromPrivate() error = %v", err)
172 }
173 now := time.Now()
174 baseRoute := types.HopRoute{
portal/multihop_local_test.go
+12 -12
@@ -17,10 +17,10 @@ import (
17 "github.com/gosuda/portal-tunnel/v2/portal/acme"
18 "github.com/gosuda/portal-tunnel/v2/portal/auth"
19 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
20 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
21 "github.com/gosuda/portal-tunnel/v2/portal/overlay"
22 "github.com/gosuda/portal-tunnel/v2/sdk"
23 "github.com/gosuda/portal-tunnel/v2/types"
23 - "github.com/gosuda/portal-tunnel/v2/utils"
24 )
25
26 type localRelayCluster struct {
@@ -54,10 +54,10 @@ func (o *fakeOverlay) Config() overlay.Config {
54 return o.cfg.Copy()
55 }
56
57 -func (o *fakeOverlay) Sync(states []discovery.RelayState) error {
58 - synced := make(map[string]struct{}, len(states))
59 - for _, state := range states {
60 - overlayIP, err := utils.DeriveWireGuardOverlayIPv4(state.Descriptor.WireGuardPublicKey)
57 +func (o *fakeOverlay) Sync(descriptors []types.RelayDescriptor) error {
58 + synced := make(map[string]struct{}, len(descriptors))
59 + for _, desc := range descriptors {
60 + overlayIP, err := identity.DeriveWireGuardOverlayIPv4(desc.WireGuardPublicKey)
61 if err != nil {
62 return err
63 }
@@ -178,13 +178,13 @@ func startLocalRelay(t *testing.T, ctx context.Context, spec localRelaySpec) *lo
178 spec.ServerMutator(server)
179 }
180
181 - wgPrivate, err := utils.GenerateWireGuardPrivateKey()
181 + wgPrivate, err := identity.GenerateWireGuardPrivateKey()
182 if err != nil {
183 - t.Fatalf("GenerateWireGuardPrivateKey(%s) error = %v", name, err)
183 + t.Fatalf("identity.GenerateWireGuardPrivateKey(%s) error = %v", name, err)
184 }
185 - wgPublic, err := utils.WireGuardPublicKeyFromPrivate(wgPrivate)
185 + wgPublic, err := identity.WireGuardPublicKeyFromPrivate(wgPrivate)
186 if err != nil {
187 - t.Fatalf("WireGuardPublicKeyFromPrivate(%s) error = %v", name, err)
187 + t.Fatalf("identity.WireGuardPublicKeyFromPrivate(%s) error = %v", name, err)
188 }
189 fakeOverlay := &fakeOverlay{cfg: overlay.Config{
190 PublicKey: wgPublic,
@@ -198,9 +198,9 @@ func startLocalRelay(t *testing.T, ctx context.Context, spec localRelaySpec) *lo
198 }
199 server.cfg.DiscoveryEnabled = true
200
201 - overlayIP, err := utils.DeriveWireGuardOverlayIPv4(wgPublic)
201 + overlayIP, err := identity.DeriveWireGuardOverlayIPv4(wgPublic)
202 if err != nil {
203 - t.Fatalf("DeriveWireGuardOverlayIPv4(%s) error = %v", name, err)
203 + t.Fatalf("identity.DeriveWireGuardOverlayIPv4(%s) error = %v", name, err)
204 }
205 return &localRelay{
206 name: name,
@@ -225,7 +225,7 @@ func (c *localRelayCluster) seedDiscovery(t *testing.T) {
225 }
226 if relay.descriptorMutator != nil {
227 relay.descriptorMutator(&desc)
228 - desc, err = auth.SignRelayDescriptor(desc, relay.server.identity.PrivateKey)
228 + desc, err = auth.SignRelayDescriptor(desc, relay.server.authority)
229 if err != nil {
230 t.Fatalf("SignRelayDescriptor(%s) error = %v", relay.name, err)
231 }
portal/overlay/overlay.go
+6 -7
@@ -18,7 +18,7 @@ import (
18 "github.com/hashicorp/yamux"
19 "github.com/rs/zerolog/log"
20
21 - "github.com/gosuda/portal-tunnel/v2/portal/discovery"
21 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
22 "github.com/gosuda/portal-tunnel/v2/types"
23 "github.com/gosuda/portal-tunnel/v2/utils"
24 )
@@ -60,11 +60,11 @@ func NormalizeConfig(cfg Config) (Config, error) {
60 return Config{}, errors.New("wireguard private key is required when relay overlay is enabled")
61 }
62
63 - privateKey, err := utils.NormalizeWireGuardPrivateKey(cfg.PrivateKey)
63 + privateKey, err := identity.NormalizeWireGuardPrivateKey(cfg.PrivateKey)
64 if err != nil {
65 return Config{}, fmt.Errorf("normalize wireguard private key: %w", err)
66 }
67 - publicKey, err := utils.WireGuardPublicKeyFromPrivate(privateKey)
67 + publicKey, err := identity.WireGuardPublicKeyFromPrivate(privateKey)
68 if err != nil {
69 return Config{}, fmt.Errorf("derive wireguard public key: %w", err)
70 }
@@ -468,7 +468,7 @@ func (o *Overlay) DiscoverRelay(ctx context.Context, relay types.RelayDescriptor
468 if !relay.HasOverlayPeer() {
469 return types.DiscoveryResponse{}, errors.New("relay wireguard overlay metadata is required")
470 }
471 - overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(relay.WireGuardPublicKey)
471 + overlayIPv4, err := identity.DeriveWireGuardOverlayIPv4(relay.WireGuardPublicKey)
472 if err != nil {
473 return types.DiscoveryResponse{}, err
474 }
@@ -484,14 +484,13 @@ func (o *Overlay) DiscoverRelay(ctx context.Context, relay types.RelayDescriptor
484 return resp, nil
485 }
486
487 -func (o *Overlay) Sync(relays []discovery.RelayState) error {
487 +func (o *Overlay) Sync(relays []types.RelayDescriptor) error {
488 if o == nil || o.stack == nil {
489 return nil
490 }
491
492 peers := make([]types.RelayDescriptor, 0, len(relays))
493 - for _, relay := range relays {
494 - desc := relay.Descriptor
493 + for _, desc := range relays {
494 if !desc.HasOverlayPeer() {
495 continue
496 }
portal/overlay/stack.go
+7 -7
@@ -17,8 +17,8 @@ import (
17 "golang.zx2c4.com/wireguard/device"
18 "golang.zx2c4.com/wireguard/tun/netstack"
19
20 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
21 "github.com/gosuda/portal-tunnel/v2/types"
21 - "github.com/gosuda/portal-tunnel/v2/utils"
22 )
23
24 const defaultEndpointResolveTTL = 3 * time.Second
@@ -36,7 +36,7 @@ type stack struct {
36 }
37
38 func newStack(cfg Config) (*stack, error) {
39 - canonicalPrivateKey, err := utils.NormalizeWireGuardPrivateKey(cfg.PrivateKey)
39 + canonicalPrivateKey, err := identity.NormalizeWireGuardPrivateKey(cfg.PrivateKey)
40 if err != nil {
41 return nil, fmt.Errorf("normalize wireguard private key: %w", err)
42 }
@@ -46,7 +46,7 @@ func newStack(cfg Config) (*stack, error) {
46 return nil, errors.New("wireguard listen port is invalid")
47 }
48
49 - overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(cfg.PublicKey)
49 + overlayIPv4, err := identity.DeriveWireGuardOverlayIPv4(cfg.PublicKey)
50 if err != nil {
51 return nil, fmt.Errorf("derive overlay ipv4: %w", err)
52 }
@@ -61,7 +61,7 @@ func newStack(cfg Config) (*stack, error) {
61 }
62
63 wgDevice := device.NewDevice(tunDevice, conn.NewDefaultBind(), device.NewLogger(device.LogLevelError, "portal-wg"))
64 - privateKeyHex, err := utils.WireGuardKeyHex(canonicalPrivateKey)
64 + privateKeyHex, err := identity.WireGuardKeyHex(canonicalPrivateKey)
65 if err != nil {
66 wgDevice.Close()
67 <-wgDevice.Wait()
@@ -143,15 +143,15 @@ func (s *stack) ApplyPeers(peers []types.RelayDescriptor) error {
143
144 for _, peer := range peers {
145 peerKey := strings.TrimSpace(peer.WireGuardPublicKey)
146 - overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(peer.WireGuardPublicKey)
146 + overlayIPv4, err := identity.DeriveWireGuardOverlayIPv4(peer.WireGuardPublicKey)
147 if err != nil {
148 continue
149 }
150 - wireGuardEndpoint, err := utils.RelayWireGuardEndpoint(peer)
150 + wireGuardEndpoint, err := identity.RelayWireGuardEndpoint(peer)
151 if err != nil {
152 continue
153 }
154 - publicKeyHex, err := utils.WireGuardKeyHex(peer.WireGuardPublicKey)
154 + publicKeyHex, err := identity.WireGuardKeyHex(peer.WireGuardPublicKey)
155 if err != nil {
156 return fmt.Errorf("normalize peer %q public key: %w", peerKey, err)
157 }
portal/server.go
+19 -11
@@ -21,6 +21,7 @@ import (
21 "github.com/gosuda/portal-tunnel/v2/portal/acme"
22 "github.com/gosuda/portal-tunnel/v2/portal/auth"
23 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
24 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
25 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
26 "github.com/gosuda/portal-tunnel/v2/portal/overlay"
27 "github.com/gosuda/portal-tunnel/v2/portal/policy"
@@ -60,7 +61,7 @@ type ServerConfig struct {
61
62 func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
63 cfg.PortalURL = strings.TrimSuffix(strings.TrimSpace(cfg.PortalURL), "/")
63 - cfg.IdentityPath = utils.ResolveRelayStateDir(cfg.IdentityPath)
64 + cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath)
65 if cfg.IdentityPath == "" {
66 return ServerConfig{}, errors.New("identity path is required")
67 }
@@ -114,6 +115,7 @@ type Server struct {
115
116 cfg ServerConfig
117 identity types.RelayIdentity
118 + authority identity.Authority
119 acmeManager *acme.Manager
120 proxy proxy
121
@@ -134,7 +136,7 @@ type Server struct {
136
137 type serverTestHooks struct {
138 overlayConfig func() overlay.Config
137 - syncOverlayPeers func([]discovery.RelayState) error
139 + syncOverlayPeers func([]types.RelayDescriptor) error
140 openHopStream func(context.Context, string, string) (net.Conn, error)
141 }
142
@@ -144,11 +146,15 @@ func NewServer(cfg ServerConfig) (*Server, error) {
146 return nil, err
147 }
148
147 - identity, err := utils.LoadOrCreateRelayIdentity(cfg.IdentityPath, utils.PortalRootHost(cfg.PortalURL), cfg.DiscoveryEnabled)
149 + relayIdentity, err := identity.LoadOrCreateRelayIdentity(cfg.IdentityPath, utils.PortalRootHost(cfg.PortalURL), cfg.DiscoveryEnabled)
150 if err != nil {
151 return nil, fmt.Errorf("load relay identity: %w", err)
152 }
151 - registry, err := newLeaseRegistry(cfg.UDPEnabled, cfg.TCPEnabled, cfg.MinPort, cfg.MaxPort, identity.Name, cfg.SNIPort, identity.PrivateKey, identity.PublicKey, identity.Address, cfg.PortalURL, cfg.TrustProxyHeaders, cfg.TrustedProxyCIDRs)
153 + relayAuthority, err := identity.NewLocalAuthority(relayIdentity.Identity)
154 + if err != nil {
155 + return nil, fmt.Errorf("load relay authority: %w", err)
156 + }
157 + registry, err := newLeaseRegistry(cfg.UDPEnabled, cfg.TCPEnabled, cfg.MinPort, cfg.MaxPort, relayIdentity.Name, cfg.SNIPort, relayAuthority, cfg.PortalURL, cfg.TrustProxyHeaders, cfg.TrustedProxyCIDRs)
158 if err != nil {
159 return nil, err
160 }
@@ -164,7 +170,8 @@ func NewServer(cfg ServerConfig) (*Server, error) {
170
171 server := &Server{
172 cfg: cfg,
167 - identity: identity,
173 + identity: relayIdentity,
174 + authority: relayAuthority,
175 registry: registry,
176 relaySet: relaySet,
177 announceLimiter: discovery.NewAnnounceLimiter(0, 0),
@@ -444,7 +451,8 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
451 CertPEM: certPEM,
452 KeyPEM: keyPEM,
453 }
447 - echSeed, err := s.identity.DeriveToken(
454 + echSeed, err := identity.DeriveToken(
455 + s.identity.Identity,
456 "relay-ech",
457 s.identity.EncryptedClientHelloSeed,
458 s.identity.Name,
@@ -616,15 +624,15 @@ func (s *Server) currentOverlayConfig() (overlay.Config, bool) {
624 return overlay.Config{}, false
625 }
626
619 -func (s *Server) syncOverlayPeers(states []discovery.RelayState) error {
627 +func (s *Server) syncOverlayPeers(descriptors []types.RelayDescriptor) error {
628 if s == nil {
629 return errors.New("server is unavailable")
630 }
631 if s.overlay != nil {
624 - return s.overlay.Sync(states)
632 + return s.overlay.Sync(descriptors)
633 }
634 if s.testHooks != nil && s.testHooks.syncOverlayPeers != nil {
627 - return s.testHooks.syncOverlayPeers(states)
635 + return s.testHooks.syncOverlayPeers(descriptors)
636 }
637 return errors.New("relay overlay is unavailable")
638 }
@@ -767,7 +775,7 @@ func (s *Server) startOverlay() (*overlay.Overlay, error) {
775 }
776 })
777
770 - if err := ov.Sync(s.relaySet.OverlayPeerStates()); err != nil {
778 + if err := ov.Sync(s.relaySet.OverlayPeerDescriptor()); err != nil {
779 _ = ov.Shutdown(context.Background())
780 return nil, fmt.Errorf("sync wireguard peers: %w", err)
781 }
@@ -837,5 +845,5 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error)
845 SupportsTCP: s.cfg.TCPEnabled,
846 ActiveConnections: s.proxy.activeConnectionCount(),
847 TCPBPS: s.proxy.currentTCPBPS(now),
840 - }, s.identity.PrivateKey)
848 + }, s.authority)
849 }
sdk/api_client.go
+20 -6
@@ -15,6 +15,7 @@ import (
15 "time"
16
17 "github.com/gosuda/portal-tunnel/v2/portal/auth"
18 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 "github.com/gosuda/portal-tunnel/v2/types"
20 "github.com/gosuda/portal-tunnel/v2/utils"
21 )
@@ -90,7 +91,8 @@ func (l *listener) buildHopRoutes(hopPath []types.RelayDescriptor, publicHostnam
91 hopRoutes := make([]types.HopRoute, 0, len(hopPath)-1)
92 var previousHopToken string
93 for i := 0; i < len(hopPath)-1; i++ {
93 - token, err := l.identity.DeriveToken(
94 + token, err := identity.DeriveToken(
95 + l.identity,
96 "hop-token",
97 publicHostname,
98 strconv.Itoa(i),
@@ -163,7 +165,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
165 return types.RegisterResponse{}, nil, "", "", err
166 }
167 if streamLease {
166 - routeToken, err := l.identity.DeriveToken("ech-route", publicHostname, rootHostname)
168 + routeToken, err := identity.DeriveToken(l.identity, "ech-route", publicHostname, rootHostname)
169 if err != nil {
170 return types.RegisterResponse{}, nil, "", "", err
171 }
@@ -209,7 +211,11 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
211 return types.RegisterResponse{}, nil, "", "", err
212 }
213
212 - signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, l.identity.PrivateKey)
214 + authority, err := identity.NewLocalAuthority(l.identity)
215 + if err != nil {
216 + return types.RegisterResponse{}, nil, "", "", err
217 + }
218 + signature, err := authority.SignEthereumPersonalMessage(challenge.SIWEMessage)
219 if err != nil {
220 return types.RegisterResponse{}, nil, "", "", err
221 }
@@ -223,7 +229,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
229 }, nil, &resp); err != nil {
230 return types.RegisterResponse{}, nil, "", "", err
231 }
226 - registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
232 + registeredIdentity, err := identity.NormalizeIdentity(resp.Identity)
233 if err != nil {
234 _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
235 return types.RegisterResponse{}, nil, "", "", err
@@ -265,6 +271,10 @@ func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, r
271 if l.relaySet == nil {
272 return "", 0, errors.New("multi-hop relay set is unavailable")
273 }
274 + authority, err := identity.NewLocalAuthority(l.identity)
275 + if err != nil {
276 + return "", 0, err
277 + }
278
279 now := time.Now().UTC()
280 for i := len(routes) - 1; i >= 0; i-- {
@@ -275,7 +285,7 @@ func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, r
285 }
286 route.ForwardRelay = desc
287 route.FirstSeenAt = expiresAt.Add(-30 * time.Second)
278 - route, err := auth.SignHopRoute(http.MethodPost, route, l.identity, expiresAt)
288 + route, err := auth.SignHopRoute(http.MethodPost, route, authority, expiresAt)
289 if err != nil {
290 return "", 0, err
291 }
@@ -312,8 +322,12 @@ func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, r
322
323 func (l *listener) unregisterHopRoutes(ctx context.Context, routes []types.HopRoute) error {
324 var unregisterErr error
325 + authority, err := identity.NewLocalAuthority(l.identity)
326 + if err != nil {
327 + return err
328 + }
329 for _, route := range routes {
316 - route, err := auth.SignHopRoute(http.MethodDelete, route, l.identity, time.Time{})
330 + route, err := auth.SignHopRoute(http.MethodDelete, route, authority, time.Time{})
331 if err != nil {
332 unregisterErr = errors.Join(unregisterErr, err)
333 continue
sdk/expose.go
+9 -7
@@ -15,6 +15,7 @@ import (
15 "github.com/rs/zerolog/log"
16
17 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
18 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
19 "github.com/gosuda/portal-tunnel/v2/portal/telemetry"
20 "github.com/gosuda/portal-tunnel/v2/types"
21 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -120,7 +121,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
121 listenerRelayURLs = append([]string(nil), explicitRelayURLs...)
122 }
123
123 - identity, createdIdentity, err := utils.ResolveListenerIdentity(
124 + listenerIdentity, createdIdentity, err := identity.ResolveListenerIdentity(
125 types.Identity{Name: cfg.Name},
126 cfg.TargetAddr,
127 cfg.IdentityPath,
@@ -132,7 +133,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
133 if createdIdentity {
134 log.Info().
135 Str("identity_path", strings.TrimSpace(cfg.IdentityPath)).
135 - Str("address", identity.Address).
136 + Str("address", listenerIdentity.Address).
137 Msg("generated tunnel identity and saved it to disk")
138 }
139 targetAddr, err := utils.NormalizeLoopbackTarget(cfg.TargetAddr)
@@ -150,7 +151,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
151 exposure := &Exposure{
152 cancel: cancel,
153 done: exposureCtx.Done(),
153 - identity: identity,
154 + identity: listenerIdentity,
155 explicitRelays: explicitRelayURLs,
156 TargetAddr: targetAddr,
157 UDPAddr: udpAddr,
@@ -364,7 +365,8 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
365 }
366 if e.relaySet != nil {
367 for _, state := range e.relaySet.AllRelays() {
367 - relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
368 + relay := state.Descriptor
369 + relayURL := strings.TrimSpace(relay.APIHTTPSAddr)
370 if relayURL == "" {
371 continue
372 }
@@ -373,9 +375,9 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
375 snap.Explicit = slices.Contains(explicitRelays, relayURL)
376 snap.Bootstrap = state.Bootstrap
377 snap.Banned = state.Banned
376 - snap.SupportsOverlay = state.Descriptor.SupportsOverlay
377 - snap.SupportsUDP = state.Descriptor.SupportsUDP
378 - snap.SupportsTCP = state.Descriptor.SupportsTCP
378 + snap.SupportsOverlay = relay.SupportsOverlay
379 + snap.SupportsUDP = relay.SupportsUDP
380 + snap.SupportsTCP = relay.SupportsTCP
381 relayByURL[relayURL] = snap
382 }
383 }
sdk/listener.go
+2 -1
@@ -20,6 +20,7 @@ import (
20 "github.com/rs/zerolog/log"
21
22 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
23 + "github.com/gosuda/portal-tunnel/v2/portal/identity"
24 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
25 "github.com/gosuda/portal-tunnel/v2/portal/transport"
26 "github.com/gosuda/portal-tunnel/v2/types"
@@ -850,7 +851,7 @@ func (l *listener) tenantECHMaterials(publicHostname, routeHostname string) ([]t
851 if routeHostname == "" {
852 return nil, nil, nil
853 }
853 - echSeed, err := l.identity.DeriveToken("tenant-ech", publicHostname, routeHostname)
854 + echSeed, err := identity.DeriveToken(l.identity, "tenant-ech", publicHostname, routeHostname)
855 if err != nil {
856 return nil, nil, fmt.Errorf("derive tenant ech seed: %w", err)
857 }
sdk/mitm_test.go
+4 -6
@@ -18,6 +18,7 @@ import (
18 "testing"
19 "time"
20
21 + "github.com/gosuda/portal-tunnel/v2/portal/discovery"
22 "github.com/gosuda/portal-tunnel/v2/types"
23 )
24
@@ -223,8 +224,8 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
224 Reason: types.MITMProbeReasonExporterMismatch,
225 }, nil)
226
226 - for _, state := range listener.relaySet.AggregateRelays() {
227 - if state.Descriptor.APIHTTPSAddr == relayURL.String() {
227 + for _, activeRelayURL := range listener.relaySet.PriorityRelays(discovery.ClientState{}) {
228 + if activeRelayURL == relayURL.String() {
229 t.Fatal("relay still active after mitm detection")
230 }
231 }
@@ -255,10 +256,7 @@ func TestMITMProbeDetectionWarnsWithoutBanningListener(t *testing.T) {
256 Reason: types.MITMProbeReasonExporterMismatch,
257 }, nil)
258
258 - activeRelayURLs := make([]string, 0)
259 - for _, state := range listener.relaySet.AggregateRelays() {
260 - activeRelayURLs = append(activeRelayURLs, state.Descriptor.APIHTTPSAddr)
261 - }
259 + activeRelayURLs := listener.relaySet.PriorityRelays(discovery.ClientState{})
260 if len(activeRelayURLs) != 1 || activeRelayURLs[0] != relayURL.String() {
261 t.Fatalf("ActiveRelayURLs() = %v, want [%q]", activeRelayURLs, relayURL.String())
262 }
types/identity.go
+10 -38
@@ -1,12 +1,7 @@
1 package types
2
3 import (
4 - "crypto/hmac"
5 - "crypto/sha256"
6 - "encoding/base64"
4 "encoding/json"
8 - "errors"
9 - "strconv"
5 "strings"
6 "time"
7 )
@@ -18,18 +13,20 @@ const (
13 )
14
15 type Identity struct {
21 - Name string `json:"name,omitempty"`
22 - Address string `json:"address,omitempty"`
23 - PublicKey string `json:"-"`
24 - PrivateKey string `json:"-"`
16 + Name string `json:"name,omitempty"`
17 + Address string `json:"address,omitempty"`
18 + PublicKey string `json:"-"`
19 + PrivateKey string `json:"-"`
20 + TokenSecret string `json:"-"`
21 }
22
23 func (i Identity) Copy() Identity {
24 return Identity{
29 - Name: i.Name,
30 - Address: i.Address,
31 - PublicKey: i.PublicKey,
32 - PrivateKey: i.PrivateKey,
25 + Name: i.Name,
26 + Address: i.Address,
27 + PublicKey: i.PublicKey,
28 + PrivateKey: i.PrivateKey,
29 + TokenSecret: i.TokenSecret,
30 }
31 }
32
@@ -51,10 +48,6 @@ func (i RelayIdentity) Copy() RelayIdentity {
48 }
49 }
50
54 -func (i RelayIdentity) Base() Identity {
55 - return i.Identity.Copy()
56 -}
57 -
51 func (i Identity) Key() string {
52 name := strings.TrimSpace(strings.ToLower(i.Name))
53 address := strings.TrimSpace(strings.ToLower(i.Address))
@@ -64,27 +57,6 @@ func (i Identity) Key() string {
57 return name + IdentityKeySeparator + address
58 }
59
67 -// DeriveToken derives a deterministic identity-scoped token from ordered
68 -// length-prefixed token parts. The first part should identify the token family.
69 -func (i Identity) DeriveToken(parts ...string) (string, error) {
70 - privateKey := strings.TrimSpace(i.PrivateKey)
71 - if privateKey == "" {
72 - return "", errors.New("identity private key is required")
73 - }
74 -
75 - mac := hmac.New(sha256.New, []byte(privateKey))
76 - _, _ = mac.Write([]byte("Portal identity token v1\n"))
77 - _, _ = mac.Write([]byte(i.Key()))
78 - for _, part := range parts {
79 - part = strings.TrimSpace(part)
80 - _, _ = mac.Write([]byte("\n"))
81 - _, _ = mac.Write([]byte(strconv.Itoa(len(part))))
82 - _, _ = mac.Write([]byte(":"))
83 - _, _ = mac.Write([]byte(part))
84 - }
85 - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
86 -}
87 -
60 type LeaseMetadata struct {
61 Description string `json:"description,omitempty"`
62 Owner string `json:"owner,omitempty"`
utils/name.go new
+128
@@ -0,0 +1,128 @@
1 +package utils
2 +
3 +import (
4 + "net"
5 + "net/url"
6 + "strings"
7 +)
8 +
9 +var exposeNameOpeners = []string{
10 + "arcade", "bouncy", "bravo", "bubble", "candy", "cosmic", "dapper", "electric",
11 + "fancy", "fizzy", "flashy", "fuzzy", "gentle", "glitter", "golden", "happy",
12 + "hyper", "jazzy", "jolly", "lively", "lucky", "magic", "mellow", "minty",
13 + "misty", "moonlit", "mystic", "neon", "nova", "peppy", "pixel", "playful",
14 + "poppy", "rapid", "rocket", "rowdy", "snappy", "snazzy", "sparkly", "spicy",
15 + "sprightly", "starry", "sunny", "swift", "tangy", "tidy", "toasty", "turbo",
16 + "velvet", "vivid", "wavy", "whimsy", "wild", "wonky", "zany", "zesty",
17 +}
18 +
19 +var exposeNameCenters = []string{
20 + "alpaca", "badger", "banjo", "beacon", "biscuit", "capybara", "comet", "cricket",
21 + "dragon", "falcon", "feather", "fjord", "fox", "gadget", "gecko", "gizmo",
22 + "harbor", "heron", "iguana", "jelly", "koala", "lemur", "mango", "narwhal",
23 + "nebula", "noodle", "octopus", "otter", "panda", "pepper", "phoenix", "pickle",
24 + "puffin", "quokka", "radar", "ranger", "rocket", "scooter", "seahorse", "skylark",
25 + "sprocket", "starling", "sunbeam", "taco", "thimble", "tiger", "toucan", "triton",
26 + "walrus", "widget", "willow", "wombat", "yeti", "zeppelin", "zigzag", "zinnia",
27 +}
28 +
29 +var exposeNameClosers = []string{
30 + "arcade", "beacon", "boogie", "bounce", "burst", "cascade", "chorus", "dash",
31 + "disco", "drift", "echo", "fiesta", "flare", "flash", "flight", "flip",
32 + "glow", "groove", "jam", "jive", "launch", "loop", "march", "orbit",
33 + "parade", "party", "pulse", "quest", "rally", "riot", "ripple", "rodeo",
34 + "roll", "rush", "serenade", "shuffle", "signal", "sketch", "spark", "sprint",
35 + "starlight", "stride", "sway", "swoop", "twirl", "uplift", "vibe", "voyage",
36 + "whirl", "wink", "zap", "zenith", "zip", "zoom", "zest", "zone",
37 +}
38 +
39 +const (
40 + defaultExposeTargetPort = "3000"
41 + defaultExposeTargetHost = "127.0.0.1"
42 +)
43 +
44 +// DefaultExposeName generates a deterministic 3-word DNS label from a target
45 +// address and seed using FNV-1a hashing. The algorithm matches the frontend
46 +// implementation in frontend/src/lib/exposeName.ts:buildDefaultExposeName.
47 +func DefaultExposeName(target, rawSeed string) (string, error) {
48 + seed := strings.TrimSpace(rawSeed)
49 + if cut, ok := strings.CutPrefix(seed, "cli_"); ok {
50 + seed = cut
51 + }
52 + if seed == "" {
53 + seed = "portal"
54 + }
55 +
56 + input := []byte(seed + "|" + normalizeExposeTarget(target))
57 + first := fnv1a32(input, 0x811c9dc5)
58 + second := fnv1a32(input, 0x9e3779b9)
59 + third := fnv1a32(input, 0x85ebca6b)
60 +
61 + label := strings.Join([]string{
62 + exposeNameOpeners[int(first&0xff)%len(exposeNameOpeners)],
63 + exposeNameCenters[int(second&0xff)%len(exposeNameCenters)],
64 + exposeNameClosers[int(third&0xff)%len(exposeNameClosers)],
65 + }, "-")
66 +
67 + return NormalizeDNSLabel(label)
68 +}
69 +
70 +// normalizeExposeTarget normalizes a target address for deterministic name
71 +// generation. Must match frontend/src/lib/exposeName.ts:normalizeExposeTarget.
72 +func normalizeExposeTarget(raw string) string {
73 + trimmed := strings.TrimSpace(raw)
74 + candidate := trimmed
75 + if candidate == "" {
76 + candidate = defaultExposeTargetPort
77 + }
78 +
79 + if isAllDigits(candidate) {
80 + return defaultExposeTargetHost + ":" + candidate
81 + }
82 +
83 + if strings.Contains(candidate, "://") {
84 + u, err := url.Parse(candidate)
85 + if err != nil {
86 + return candidate
87 + }
88 + if (u.Scheme == "http" || u.Scheme == "https") &&
89 + u.Host != "" &&
90 + (u.Path == "" || u.Path == "/") &&
91 + u.RawQuery == "" &&
92 + u.Fragment == "" {
93 + return u.Host
94 + }
95 + return candidate
96 + }
97 +
98 + u, err := url.Parse("tcp://" + candidate)
99 + if err != nil || u.Hostname() == "" {
100 + return candidate
101 + }
102 + port := u.Port()
103 + if port == "" {
104 + port = "80"
105 + }
106 + return net.JoinHostPort(u.Hostname(), port)
107 +}
108 +
109 +func isAllDigits(s string) bool {
110 + if s == "" {
111 + return false
112 + }
113 + for _, c := range s {
114 + if c < '0' || c > '9' {
115 + return false
116 + }
117 + }
118 + return true
119 +}
120 +
121 +func fnv1a32(data []byte, seed uint32) uint32 {
122 + h := seed
123 + for _, b := range data {
124 + h ^= uint32(b)
125 + h *= 0x01000193
126 + }
127 + return h
128 +}