diff --git a/net/secureservice/admission.go b/net/secureservice/admission.go new file mode 100644 index 000000000..a788f746a --- /dev/null +++ b/net/secureservice/admission.go @@ -0,0 +1,75 @@ +package secureservice + +import "context" + +const ( + DefaultAdmissionIdentityClaim = "anytype_identity" + DefaultAdmissionNetworkClaim = "network_id" + DefaultAdmissionSubjectClaim = "sub" + DefaultAdmissionClockSkewSec = 60 +) + +type AdmissionConfig struct { + Enabled bool `yaml:"enabled"` + Required bool `yaml:"required"` + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + JWKSURL string `yaml:"jwksUrl"` + RequiredClaims map[string]any `yaml:"requiredClaims"` + IdentityClaim string `yaml:"identityClaim"` + NetworkClaim string `yaml:"networkClaim"` + SubjectClaim string `yaml:"subjectClaim"` + ClockSkewSec int `yaml:"clockSkewSec"` +} + +func (c AdmissionConfig) WithDefaults() AdmissionConfig { + if c.IdentityClaim == "" { + c.IdentityClaim = DefaultAdmissionIdentityClaim + } + if c.NetworkClaim == "" { + c.NetworkClaim = DefaultAdmissionNetworkClaim + } + if c.SubjectClaim == "" { + c.SubjectClaim = DefaultAdmissionSubjectClaim + } + if c.ClockSkewSec == 0 { + c.ClockSkewSec = DefaultAdmissionClockSkewSec + } + return c +} + +type AdmissionRequest struct { + Token string + Identity []byte + NetworkID string + PeerID string + ClientVersion string +} + +type AdmissionClaims struct { + Subject string + Issuer string + Audience []string + NetworkID string + Identity []byte + Claims map[string]any +} + +type AdmissionDecision struct { + Allowed bool + Reason string + Claims AdmissionClaims +} + +type AdmissionVerifier interface { + VerifyAdmission(ctx context.Context, req AdmissionRequest) (AdmissionDecision, error) +} + +type NoopAdmissionVerifier struct{} + +func (NoopAdmissionVerifier) VerifyAdmission(ctx context.Context, req AdmissionRequest) (AdmissionDecision, error) { + return AdmissionDecision{ + Allowed: true, + Reason: "admission disabled", + }, nil +} diff --git a/net/secureservice/admission_checker.go b/net/secureservice/admission_checker.go new file mode 100644 index 000000000..09cdcc032 --- /dev/null +++ b/net/secureservice/admission_checker.go @@ -0,0 +1,56 @@ +package secureservice + +import ( + "context" + + "github.com/anyproto/any-sync/net/secureservice/handshake" + "github.com/anyproto/any-sync/net/secureservice/handshake/handshakeproto" +) + +func withAdmissionVerifier(ctx context.Context, checker handshake.CredentialChecker, verifier AdmissionVerifier, required bool, networkID string) handshake.CredentialChecker { + if verifier == nil { + return checker + } + if ctx == nil { + ctx = context.Background() + } + return admissionVerifierCredentialChecker{ + CredentialChecker: checker, + ctx: ctx, + verifier: verifier, + required: required, + networkID: networkID, + } +} + +type admissionVerifierCredentialChecker struct { + handshake.CredentialChecker + ctx context.Context + verifier AdmissionVerifier + required bool + networkID string +} + +func (a admissionVerifierCredentialChecker) CheckCredential(remotePeerId string, cred *handshakeproto.Credentials) (handshake.Result, error) { + res, err := a.CredentialChecker.CheckCredential(remotePeerId, cred) + if err != nil { + return handshake.Result{}, err + } + if res.AdmissionToken == "" { + if a.required { + return handshake.Result{}, handshake.ErrInvalidCredentials + } + return res, nil + } + decision, err := a.verifier.VerifyAdmission(a.ctx, AdmissionRequest{ + Token: res.AdmissionToken, + Identity: res.Identity, + NetworkID: a.networkID, + PeerID: remotePeerId, + ClientVersion: res.ClientVersion, + }) + if err != nil || !decision.Allowed { + return handshake.Result{}, handshake.ErrInvalidCredentials + } + return res, nil +} diff --git a/net/secureservice/admission_jwt.go b/net/secureservice/admission_jwt.go new file mode 100644 index 000000000..2d39bd718 --- /dev/null +++ b/net/secureservice/admission_jwt.go @@ -0,0 +1,495 @@ +package secureservice + +import ( + "bytes" + "context" + stdcrypto "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "time" + + anycrypto "github.com/anyproto/any-sync/util/crypto" +) + +var ( + // ErrAdmissionDenied is returned when an admission request is rejected. + ErrAdmissionDenied = errors.New("admission denied") + // ErrAdmissionInvalidConfig is returned when JWT admission verifier configuration is incomplete or invalid. + ErrAdmissionInvalidConfig = errors.New("invalid admission config") + // ErrAdmissionInvalidToken is returned when an admission token is malformed or fails validation. + ErrAdmissionInvalidToken = errors.New("invalid admission token") +) + +type jwtAdmissionVerifier struct { + conf AdmissionConfig + keys map[string]jwtPublicKey + now func() time.Time +} + +type jwtPublicKey struct { + algorithm string + key any +} + +type jwtHeader struct { + Algorithm string `json:"alg"` + KeyID string `json:"kid"` +} + +type jsonWebKeySet struct { + Keys []jsonWebKey `json:"keys"` +} + +type jsonWebKey struct { + KeyType string `json:"kty"` + KeyID string `json:"kid"` + Use string `json:"use"` + Algorithm string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + Curve string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// NewJWTAdmissionVerifier creates an AdmissionVerifier that validates JWTs against a static JWKS document. +func NewJWTAdmissionVerifier(conf AdmissionConfig, jwks []byte) (AdmissionVerifier, error) { + return newJWTAdmissionVerifier(conf, jwks, time.Now) +} + +func newJWTAdmissionVerifier(conf AdmissionConfig, jwks []byte, now func() time.Time) (*jwtAdmissionVerifier, error) { + conf = conf.WithDefaults() + if conf.Issuer == "" || conf.Audience == "" || conf.IdentityClaim == "" || conf.NetworkClaim == "" || conf.SubjectClaim == "" { + return nil, ErrAdmissionInvalidConfig + } + keys, err := parseJWKS(jwks) + if err != nil { + return nil, err + } + if len(keys) == 0 { + return nil, ErrAdmissionInvalidConfig + } + if now == nil { + now = time.Now + } + return &jwtAdmissionVerifier{ + conf: conf, + keys: keys, + now: now, + }, nil +} + +func (v *jwtAdmissionVerifier) VerifyAdmission(ctx context.Context, req AdmissionRequest) (AdmissionDecision, error) { + if err := ctx.Err(); err != nil { + return AdmissionDecision{Allowed: false, Reason: err.Error()}, err + } + if req.Token == "" || len(req.Identity) == 0 || req.NetworkID == "" { + return v.deny("missing admission token, identity, or network id", ErrAdmissionInvalidToken) + } + + header, claims, signingInput, signature, err := parseJWT(req.Token) + if err != nil { + return v.deny("malformed admission token", err) + } + key, ok := v.keys[header.KeyID] + if !ok { + return v.deny("unknown signing key", ErrAdmissionInvalidToken) + } + if err = verifyJWTSignature(header.Algorithm, key, signingInput, signature); err != nil { + return v.deny("invalid admission token signature", err) + } + + decisionClaims, err := v.validateClaims(req, claims) + if err != nil { + return v.deny(err.Error(), err) + } + return AdmissionDecision{ + Allowed: true, + Reason: "admission allowed", + Claims: decisionClaims, + }, nil +} + +func (v *jwtAdmissionVerifier) deny(reason string, err error) (AdmissionDecision, error) { + return AdmissionDecision{Allowed: false, Reason: reason}, errors.Join(ErrAdmissionDenied, err) +} + +func parseJWT(token string) (jwtHeader, map[string]any, []byte, []byte, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + + var header jwtHeader + if err = decodeJSON(headerBytes, &header); err != nil { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + if header.Algorithm == "" || header.KeyID == "" { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + + claims := map[string]any{} + if err = decodeJSON(payloadBytes, &claims); err != nil { + return jwtHeader{}, nil, nil, nil, ErrAdmissionInvalidToken + } + return header, claims, []byte(parts[0] + "." + parts[1]), signature, nil +} + +func decodeJSON(data []byte, v any) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + return dec.Decode(v) +} + +func parseJWKS(jwks []byte) (map[string]jwtPublicKey, error) { + var set jsonWebKeySet + if err := decodeJSON(jwks, &set); err != nil { + return nil, ErrAdmissionInvalidConfig + } + keys := make(map[string]jwtPublicKey, len(set.Keys)) + for _, key := range set.Keys { + if key.KeyID == "" || key.Use == "enc" { + continue + } + parsed, err := parseJWK(key) + if err != nil { + return nil, err + } + keys[key.KeyID] = parsed + } + return keys, nil +} + +func parseJWK(key jsonWebKey) (jwtPublicKey, error) { + switch key.KeyType { + case "RSA": + return parseRSAJWK(key) + case "EC": + return parseECJWK(key) + case "OKP": + return parseOKPJWK(key) + default: + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } +} + +func parseRSAJWK(key jsonWebKey) (jwtPublicKey, error) { + n, err := base64.RawURLEncoding.DecodeString(key.N) + if err != nil { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + e, err := base64.RawURLEncoding.DecodeString(key.E) + if err != nil { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + exponent := int(new(big.Int).SetBytes(e).Int64()) + if exponent == 0 || len(n) == 0 { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + return jwtPublicKey{ + algorithm: key.Algorithm, + key: &rsa.PublicKey{ + N: new(big.Int).SetBytes(n), + E: exponent, + }, + }, nil +} + +func parseECJWK(key jsonWebKey) (jwtPublicKey, error) { + x, err := base64.RawURLEncoding.DecodeString(key.X) + if err != nil { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + y, err := base64.RawURLEncoding.DecodeString(key.Y) + if err != nil { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + curve := curveForJWK(key.Curve) + xCoord := new(big.Int).SetBytes(x) + yCoord := new(big.Int).SetBytes(y) + if curve == nil || len(x) == 0 || len(y) == 0 || !curve.IsOnCurve(xCoord, yCoord) { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + return jwtPublicKey{ + algorithm: key.Algorithm, + key: &ecdsa.PublicKey{ + Curve: curve, + X: xCoord, + Y: yCoord, + }, + }, nil +} + +func curveForJWK(curve string) elliptic.Curve { + switch curve { + case "P-256": + return elliptic.P256() + case "P-384": + return elliptic.P384() + case "P-521": + return elliptic.P521() + default: + return nil + } +} + +func parseOKPJWK(key jsonWebKey) (jwtPublicKey, error) { + if key.Curve != "Ed25519" { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + x, err := base64.RawURLEncoding.DecodeString(key.X) + if err != nil || len(x) != ed25519.PublicKeySize { + return jwtPublicKey{}, ErrAdmissionInvalidConfig + } + return jwtPublicKey{ + algorithm: key.Algorithm, + key: ed25519.PublicKey(x), + }, nil +} + +func verifyJWTSignature(algorithm string, key jwtPublicKey, signingInput []byte, signature []byte) error { + if key.algorithm != "" && key.algorithm != algorithm { + return ErrAdmissionInvalidToken + } + switch algorithm { + case "RS256": + digest := sha256.Sum256(signingInput) + return verifyRSA(key.key, stdcrypto.SHA256, digest[:], signature) + case "RS384": + digest := sha512.Sum384(signingInput) + return verifyRSA(key.key, stdcrypto.SHA384, digest[:], signature) + case "RS512": + digest := sha512.Sum512(signingInput) + return verifyRSA(key.key, stdcrypto.SHA512, digest[:], signature) + case "ES256": + digest := sha256.Sum256(signingInput) + return verifyECDSA(key.key, digest[:], signature, 32) + case "ES384": + digest := sha512.Sum384(signingInput) + return verifyECDSA(key.key, digest[:], signature, 48) + case "ES512": + digest := sha512.Sum512(signingInput) + return verifyECDSA(key.key, digest[:], signature, 66) + case "EdDSA": + pub, ok := key.key.(ed25519.PublicKey) + if !ok || !ed25519.Verify(pub, signingInput, signature) { + return ErrAdmissionInvalidToken + } + return nil + default: + return ErrAdmissionInvalidToken + } +} + +func verifyRSA(key any, hash stdcrypto.Hash, digest []byte, signature []byte) error { + pub, ok := key.(*rsa.PublicKey) + if !ok { + return ErrAdmissionInvalidToken + } + if err := rsa.VerifyPKCS1v15(pub, hash, digest, signature); err != nil { + return ErrAdmissionInvalidToken + } + return nil +} + +func verifyECDSA(key any, digest []byte, signature []byte, keyBytes int) error { + pub, ok := key.(*ecdsa.PublicKey) + if !ok || len(signature) != keyBytes*2 { + return ErrAdmissionInvalidToken + } + r := new(big.Int).SetBytes(signature[:keyBytes]) + s := new(big.Int).SetBytes(signature[keyBytes:]) + if !ecdsa.Verify(pub, digest, r, s) { + return ErrAdmissionInvalidToken + } + return nil +} + +func (v *jwtAdmissionVerifier) validateClaims(req AdmissionRequest, claims map[string]any) (AdmissionClaims, error) { + now := v.now() + if err := validateStringClaim(claims, "iss", v.conf.Issuer); err != nil { + return AdmissionClaims{}, err + } + audience, err := validateAudienceClaim(claims, v.conf.Audience) + if err != nil { + return AdmissionClaims{}, err + } + if err = validateExpiration(claims, now, time.Duration(v.conf.ClockSkewSec)*time.Second); err != nil { + return AdmissionClaims{}, err + } + if err = validateStringClaim(claims, v.conf.NetworkClaim, req.NetworkID); err != nil { + return AdmissionClaims{}, err + } + accountID, err := accountIDFromIdentity(req.Identity) + if err != nil { + return AdmissionClaims{}, ErrAdmissionInvalidToken + } + if err = validateStringClaim(claims, v.conf.IdentityClaim, accountID); err != nil { + return AdmissionClaims{}, err + } + subject, err := stringClaim(claims, v.conf.SubjectClaim) + if err != nil || subject == "" { + return AdmissionClaims{}, ErrAdmissionInvalidToken + } + for name, expected := range v.conf.RequiredClaims { + actual, ok := claims[name] + if !ok || !claimMatches(actual, expected) { + return AdmissionClaims{}, fmt.Errorf("required claim %q is missing or invalid: %w", name, ErrAdmissionInvalidToken) + } + } + return AdmissionClaims{ + Subject: subject, + Issuer: v.conf.Issuer, + Audience: audience, + NetworkID: req.NetworkID, + Identity: req.Identity, + Claims: claims, + }, nil +} + +func accountIDFromIdentity(identity []byte) (string, error) { + pub, err := anycrypto.UnmarshalEd25519PublicKeyProto(identity) + if err != nil { + return "", err + } + return pub.Account(), nil +} + +func validateStringClaim(claims map[string]any, name string, expected string) error { + actual, err := stringClaim(claims, name) + if err != nil || actual != expected { + return ErrAdmissionInvalidToken + } + return nil +} + +func stringClaim(claims map[string]any, name string) (string, error) { + value, ok := claims[name].(string) + if !ok { + return "", ErrAdmissionInvalidToken + } + return value, nil +} + +func validateAudienceClaim(claims map[string]any, expected string) ([]string, error) { + audience := stringListClaim(claims["aud"]) + for _, value := range audience { + if value == expected { + return audience, nil + } + } + return nil, ErrAdmissionInvalidToken +} + +func stringListClaim(value any) []string { + switch value := value.(type) { + case string: + return []string{value} + case []any: + result := make([]string, 0, len(value)) + for _, item := range value { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result + default: + return nil + } +} + +func validateExpiration(claims map[string]any, now time.Time, skew time.Duration) error { + exp, ok, err := numericDateClaim(claims, "exp") + if err != nil || !ok || now.After(exp.Add(skew)) { + return ErrAdmissionInvalidToken + } + if nbf, ok, err := numericDateClaim(claims, "nbf"); err != nil || ok && now.Add(skew).Before(nbf) { + return ErrAdmissionInvalidToken + } + if iat, ok, err := numericDateClaim(claims, "iat"); err != nil || ok && now.Add(skew).Before(iat) { + return ErrAdmissionInvalidToken + } + return nil +} + +func numericDateClaim(claims map[string]any, name string) (time.Time, bool, error) { + value, ok := claims[name] + if !ok { + return time.Time{}, false, nil + } + seconds, err := int64ClaimValue(value) + if err != nil { + return time.Time{}, false, err + } + return time.Unix(seconds, 0), true, nil +} + +func int64ClaimValue(value any) (int64, error) { + switch value := value.(type) { + case json.Number: + return value.Int64() + case float64: + return int64(value), nil + case int64: + return value, nil + case int: + return int64(value), nil + default: + return 0, ErrAdmissionInvalidToken + } +} + +func claimMatches(actual any, expected any) bool { + if values, ok := actual.([]any); ok { + for _, value := range values { + if claimMatches(value, expected) { + return true + } + } + return false + } + switch expected := expected.(type) { + case string: + actual, ok := actual.(string) + return ok && actual == expected + case bool: + actual, ok := actual.(bool) + return ok && actual == expected + case int: + return claimNumberMatches(actual, int64(expected)) + case int64: + return claimNumberMatches(actual, expected) + case json.Number: + expectedInt, err := expected.Int64() + return err == nil && claimNumberMatches(actual, expectedInt) + default: + return actual == expected + } +} + +func claimNumberMatches(actual any, expected int64) bool { + actualInt, err := int64ClaimValue(actual) + return err == nil && actualInt == expected +} diff --git a/net/secureservice/admission_jwt_test.go b/net/secureservice/admission_jwt_test.go new file mode 100644 index 000000000..882d4cbb8 --- /dev/null +++ b/net/secureservice/admission_jwt_test.go @@ -0,0 +1,227 @@ +package secureservice + +import ( + "context" + stdcrypto "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "math/big" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJWTAdmissionVerifier_VerifyAdmission(t *testing.T) { + now := time.Unix(1700000000, 0) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + account := newTestAccData(t) + identity, err := account.SignKey.GetPublic().Marshall() + require.NoError(t, err) + + verifier := newTestJWTAdmissionVerifier(t, privateKey, now) + token := signAdmissionToken(t, privateKey, map[string]any{ + "iss": "https://issuer.example", + "aud": []string{"any-sync"}, + "sub": "user-1", + "network_id": "network-1", + "anytype_identity": account.SignKey.GetPublic().Account(), + "groups": []string{"docs-users"}, + "exp": now.Add(time.Hour).Unix(), + "nbf": now.Add(-time.Minute).Unix(), + "iat": now.Add(-time.Minute).Unix(), + }) + + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{ + Token: token, + Identity: identity, + NetworkID: "network-1", + }) + + require.NoError(t, err) + assert.True(t, decision.Allowed) + assert.Equal(t, "admission allowed", decision.Reason) + assert.Equal(t, "user-1", decision.Claims.Subject) + assert.Equal(t, "network-1", decision.Claims.NetworkID) + assert.Equal(t, identity, decision.Claims.Identity) +} + +func TestJWTAdmissionVerifier_DeniesWrongAudience(t *testing.T) { + now := time.Unix(1700000000, 0) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + account := newTestAccData(t) + identity, err := account.SignKey.GetPublic().Marshall() + require.NoError(t, err) + + verifier := newTestJWTAdmissionVerifier(t, privateKey, now) + token := signAdmissionToken(t, privateKey, map[string]any{ + "iss": "https://issuer.example", + "aud": "other-audience", + "sub": "user-1", + "network_id": "network-1", + "anytype_identity": account.SignKey.GetPublic().Account(), + "groups": []string{"docs-users"}, + "exp": now.Add(time.Hour).Unix(), + }) + + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{ + Token: token, + Identity: identity, + NetworkID: "network-1", + }) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAdmissionDenied)) + assert.False(t, decision.Allowed) +} + +func TestJWTAdmissionVerifier_DeniesWrongIdentity(t *testing.T) { + now := time.Unix(1700000000, 0) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + account := newTestAccData(t) + otherAccount := newTestAccData(t) + identity, err := account.SignKey.GetPublic().Marshall() + require.NoError(t, err) + + verifier := newTestJWTAdmissionVerifier(t, privateKey, now) + token := signAdmissionToken(t, privateKey, map[string]any{ + "iss": "https://issuer.example", + "aud": "any-sync", + "sub": "user-1", + "network_id": "network-1", + "anytype_identity": otherAccount.SignKey.GetPublic().Account(), + "groups": []string{"docs-users"}, + "exp": now.Add(time.Hour).Unix(), + }) + + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{ + Token: token, + Identity: identity, + NetworkID: "network-1", + }) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAdmissionDenied)) + assert.False(t, decision.Allowed) +} + +func TestJWTAdmissionVerifier_DeniesTamperedToken(t *testing.T) { + now := time.Unix(1700000000, 0) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + account := newTestAccData(t) + identity, err := account.SignKey.GetPublic().Marshall() + require.NoError(t, err) + + verifier := newTestJWTAdmissionVerifier(t, privateKey, now) + token := signAdmissionToken(t, privateKey, map[string]any{ + "iss": "https://issuer.example", + "aud": "any-sync", + "sub": "user-1", + "network_id": "network-1", + "anytype_identity": account.SignKey.GetPublic().Account(), + "groups": []string{"docs-users"}, + "exp": now.Add(time.Hour).Unix(), + }) + tokenParts := strings.Split(token, ".") + require.Len(t, tokenParts, 3) + tokenParts[2] = base64.RawURLEncoding.EncodeToString([]byte("bad-signature")) + + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{ + Token: strings.Join(tokenParts, "."), + Identity: identity, + NetworkID: "network-1", + }) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAdmissionDenied)) + assert.False(t, decision.Allowed) +} + +func TestJWTAdmissionVerifier_DeniesExpiredToken(t *testing.T) { + now := time.Unix(1700000000, 0) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + account := newTestAccData(t) + identity, err := account.SignKey.GetPublic().Marshall() + require.NoError(t, err) + + verifier := newTestJWTAdmissionVerifier(t, privateKey, now) + token := signAdmissionToken(t, privateKey, map[string]any{ + "iss": "https://issuer.example", + "aud": "any-sync", + "sub": "user-1", + "network_id": "network-1", + "anytype_identity": account.SignKey.GetPublic().Account(), + "groups": []string{"docs-users"}, + "exp": now.Add(-2 * time.Minute).Unix(), + }) + + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{ + Token: token, + Identity: identity, + NetworkID: "network-1", + }) + + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAdmissionDenied)) + assert.False(t, decision.Allowed) +} + +func newTestJWTAdmissionVerifier(t *testing.T, privateKey *rsa.PrivateKey, now time.Time) *jwtAdmissionVerifier { + verifier, err := newJWTAdmissionVerifier(AdmissionConfig{ + Issuer: "https://issuer.example", + Audience: "any-sync", + RequiredClaims: map[string]any{"groups": "docs-users"}, + }, rsaJWKS(t, &privateKey.PublicKey, "test-key"), func() time.Time { return now }) + require.NoError(t, err) + return verifier +} + +func signAdmissionToken(t *testing.T, privateKey *rsa.PrivateKey, claims map[string]any) string { + header := map[string]any{ + "alg": "RS256", + "kid": "test-key", + } + encodedHeader := encodeJWTPart(t, header) + encodedClaims := encodeJWTPart(t, claims) + signingInput := encodedHeader + "." + encodedClaims + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, stdcrypto.SHA256, digest[:]) + require.NoError(t, err) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func rsaJWKS(t *testing.T, publicKey *rsa.PublicKey, keyID string) []byte { + return mustJSON(t, map[string]any{ + "keys": []map[string]any{ + { + "kty": "RSA", + "kid": keyID, + "use": "sig", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()), + }, + }, + }) +} + +func encodeJWTPart(t *testing.T, value any) string { + return base64.RawURLEncoding.EncodeToString(mustJSON(t, value)) +} + +func mustJSON(t *testing.T, value any) []byte { + data, err := json.Marshal(value) + require.NoError(t, err) + return data +} diff --git a/net/secureservice/admission_test.go b/net/secureservice/admission_test.go new file mode 100644 index 000000000..8a59ca054 --- /dev/null +++ b/net/secureservice/admission_test.go @@ -0,0 +1,78 @@ +package secureservice + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestAdmissionConfig_WithDefaults(t *testing.T) { + conf := AdmissionConfig{}.WithDefaults() + + assert.False(t, conf.Enabled) + assert.False(t, conf.Required) + assert.Equal(t, DefaultAdmissionIdentityClaim, conf.IdentityClaim) + assert.Equal(t, DefaultAdmissionNetworkClaim, conf.NetworkClaim) + assert.Equal(t, DefaultAdmissionSubjectClaim, conf.SubjectClaim) + assert.Equal(t, DefaultAdmissionClockSkewSec, conf.ClockSkewSec) +} + +func TestAdmissionConfig_WithDefaultsPreservesConfiguredValues(t *testing.T) { + conf := AdmissionConfig{ + Enabled: true, + Required: true, + IdentityClaim: "identity", + NetworkClaim: "network", + SubjectClaim: "subject", + ClockSkewSec: 120, + }.WithDefaults() + + assert.True(t, conf.Enabled) + assert.True(t, conf.Required) + assert.Equal(t, "identity", conf.IdentityClaim) + assert.Equal(t, "network", conf.NetworkClaim) + assert.Equal(t, "subject", conf.SubjectClaim) + assert.Equal(t, 120, conf.ClockSkewSec) +} + +func TestAdmissionConfig_YAML(t *testing.T) { + var conf Config + err := yaml.Unmarshal([]byte(` +admission: + enabled: true + required: true + issuer: https://issuer.example + audience: any-sync + jwksUrl: https://issuer.example/.well-known/jwks.json + requiredClaims: + group: docs-users + identityClaim: anytype_id + networkClaim: sync_network + subjectClaim: user + clockSkewSec: 30 +`), &conf) + require.NoError(t, err) + + assert.True(t, conf.Admission.Enabled) + assert.True(t, conf.Admission.Required) + assert.Equal(t, "https://issuer.example", conf.Admission.Issuer) + assert.Equal(t, "any-sync", conf.Admission.Audience) + assert.Equal(t, "https://issuer.example/.well-known/jwks.json", conf.Admission.JWKSURL) + assert.Equal(t, map[string]any{"group": "docs-users"}, conf.Admission.RequiredClaims) + assert.Equal(t, "anytype_id", conf.Admission.IdentityClaim) + assert.Equal(t, "sync_network", conf.Admission.NetworkClaim) + assert.Equal(t, "user", conf.Admission.SubjectClaim) + assert.Equal(t, 30, conf.Admission.ClockSkewSec) +} + +func TestNoopAdmissionVerifier_AllowsRequests(t *testing.T) { + verifier := NoopAdmissionVerifier{} + decision, err := verifier.VerifyAdmission(context.Background(), AdmissionRequest{}) + + require.NoError(t, err) + assert.True(t, decision.Allowed) + assert.Equal(t, "admission disabled", decision.Reason) +} diff --git a/net/secureservice/config.go b/net/secureservice/config.go index 7d5eb3ae4..98a8786bc 100644 --- a/net/secureservice/config.go +++ b/net/secureservice/config.go @@ -6,6 +6,8 @@ type ctxKey int const ( allowAccountCheck ctxKey = iota + outboundAdmissionToken + remoteAdmissionToken ) type configGetter interface { @@ -13,8 +15,9 @@ type configGetter interface { } type Config struct { - RequireClientAuth bool `yaml:"requireClientAuth"` - CompatibleVersions []uint32 `yaml:"compatibleVersions"` + RequireClientAuth bool `yaml:"requireClientAuth"` + CompatibleVersions []uint32 `yaml:"compatibleVersions"` + Admission AdmissionConfig `yaml:"admission"` } // CtxAllowAccountCheck upgrades the context to allow identity check on handshake @@ -29,3 +32,40 @@ func CtxIsAccountCheckAllowed(ctx context.Context) bool { } return false } + +// CtxWithOutboundAdmissionToken stores the local admission token to send during the handshake. +func CtxWithOutboundAdmissionToken(ctx context.Context, token string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, outboundAdmissionToken, token) +} + +// CtxOutboundAdmissionToken returns the local admission token to send during the handshake. +func CtxOutboundAdmissionToken(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(outboundAdmissionToken).(string); ok { + return v + } + return "" +} + +func ctxWithRemoteAdmissionToken(ctx context.Context, token string) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, remoteAdmissionToken, token) +} + +// CtxRemoteAdmissionToken returns the remote admission token received during the handshake. +func CtxRemoteAdmissionToken(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(remoteAdmissionToken).(string); ok { + return v + } + return "" +} diff --git a/net/secureservice/credential.go b/net/secureservice/credential.go index a0051b51a..d9f9dbf26 100644 --- a/net/secureservice/credential.go +++ b/net/secureservice/credential.go @@ -43,8 +43,9 @@ func (n noVerifyChecker) CheckCredential(remotePeerId string, cred *handshakepro return } return handshake.Result{ - ProtoVersion: cred.Version, - ClientVersion: cred.ClientVersion, + ProtoVersion: cred.Version, + ClientVersion: cred.ClientVersion, + AdmissionToken: cred.AdmissionToken, }, nil } @@ -117,8 +118,34 @@ func (p *peerSignVerifier) CheckCredential(remotePeerId string, cred *handshakep return } return handshake.Result{ - Identity: msg.Identity, - ProtoVersion: cred.Version, - ClientVersion: cred.ClientVersion, + Identity: msg.Identity, + ProtoVersion: cred.Version, + ClientVersion: cred.ClientVersion, + AdmissionToken: cred.AdmissionToken, }, nil } + +func withAdmissionToken(checker handshake.CredentialChecker, token string) handshake.CredentialChecker { + if token == "" { + return checker + } + return admissionTokenCredentialChecker{ + CredentialChecker: checker, + token: token, + } +} + +type admissionTokenCredentialChecker struct { + handshake.CredentialChecker + token string +} + +func (a admissionTokenCredentialChecker) MakeCredentials(remotePeerId string) *handshakeproto.Credentials { + cred := a.CredentialChecker.MakeCredentials(remotePeerId) + if cred == nil { + return nil + } + withToken := *cred + withToken.AdmissionToken = a.token + return &withToken +} diff --git a/net/secureservice/credential_test.go b/net/secureservice/credential_test.go index 94e24f19e..de69fc2e7 100644 --- a/net/secureservice/credential_test.go +++ b/net/secureservice/credential_test.go @@ -1,6 +1,8 @@ package secureservice import ( + "context" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -8,6 +10,7 @@ import ( "github.com/anyproto/any-sync/commonspace/object/accountdata" "github.com/anyproto/any-sync/net/secureservice/handshake" + "github.com/anyproto/any-sync/net/secureservice/handshake/handshakeproto" "github.com/anyproto/any-sync/testutil/accounttest" ) @@ -25,18 +28,126 @@ func TestPeerSignVerifier_CheckCredential(t *testing.T) { cr1 := cc1.MakeCredentials(c1) cr2 := cc2.MakeCredentials(c2) + cr1.AdmissionToken = "token-1" + cr2.AdmissionToken = "token-2" res, err := cc1.CheckCredential(c1, cr2) assert.NoError(t, err) assert.Equal(t, identity2, res.Identity) + assert.Equal(t, "token-2", res.AdmissionToken) res2, err := cc2.CheckCredential(c2, cr1) assert.NoError(t, err) assert.Equal(t, identity1, res2.Identity) + assert.Equal(t, "token-1", res2.AdmissionToken) _, err = cc1.CheckCredential(c1, cr1) assert.EqualError(t, err, handshake.ErrInvalidCredentials.Error()) } +func TestNoVerifyChecker_CheckCredentialCopiesAdmissionToken(t *testing.T) { + cc := newNoVerifyChecker(1, []uint32{1}, "test:v1") + cred := cc.MakeCredentials("peer-id") + cred.AdmissionToken = "admission-token" + + res, err := cc.CheckCredential("peer-id", cred) + require.NoError(t, err) + assert.Equal(t, "admission-token", res.AdmissionToken) +} + +func TestAdmissionTokenCheckerDoesNotMutateBaseCredentials(t *testing.T) { + cc := newNoVerifyChecker(1, []uint32{1}, "test:v1") + wrapped := withAdmissionToken(cc, "admission-token") + + wrappedCred := wrapped.MakeCredentials("peer-id") + baseCred := cc.MakeCredentials("peer-id") + + assert.Equal(t, "admission-token", wrappedCred.AdmissionToken) + assert.Empty(t, baseCred.AdmissionToken) +} + +func TestAdmissionVerifierCredentialChecker_AllowsAdmissionToken(t *testing.T) { + baseResult := handshake.Result{ + Identity: []byte("identity"), + ProtoVersion: 1, + ClientVersion: "test:v1", + AdmissionToken: "admission-token", + } + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{result: baseResult}, verifier, true, "network-1") + + res, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + require.NoError(t, err) + assert.Equal(t, baseResult, res) + require.Len(t, verifier.Requests(), 1) + assert.Equal(t, AdmissionRequest{ + Token: "admission-token", + Identity: []byte("identity"), + NetworkID: "network-1", + PeerID: "peer-id", + ClientVersion: "test:v1", + }, verifier.Requests()[0]) +} + +func TestAdmissionVerifierCredentialChecker_DeniesRejectedAdmissionToken(t *testing.T) { + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: false, Reason: "not admitted"}, + } + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{result: handshake.Result{AdmissionToken: "admission-token"}}, verifier, true, "network-1") + + _, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + assert.Equal(t, handshake.ErrInvalidCredentials, err) + require.Len(t, verifier.Requests(), 1) +} + +func TestAdmissionVerifierCredentialChecker_DeniesVerifierError(t *testing.T) { + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: false, Reason: "invalid"}, + err: ErrAdmissionInvalidToken, + } + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{result: handshake.Result{AdmissionToken: "admission-token"}}, verifier, true, "network-1") + + _, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + assert.Equal(t, handshake.ErrInvalidCredentials, err) + require.Len(t, verifier.Requests(), 1) +} + +func TestAdmissionVerifierCredentialChecker_SkipsMissingOptionalToken(t *testing.T) { + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + baseResult := handshake.Result{ClientVersion: "test:v1"} + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{result: baseResult}, verifier, false, "network-1") + + res, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + require.NoError(t, err) + assert.Equal(t, baseResult, res) + assert.Empty(t, verifier.Requests()) +} + +func TestAdmissionVerifierCredentialChecker_DeniesMissingRequiredToken(t *testing.T) { + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{result: handshake.Result{}}, verifier, true, "network-1") + + _, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + assert.Equal(t, handshake.ErrInvalidCredentials, err) + assert.Empty(t, verifier.Requests()) +} + +func TestAdmissionVerifierCredentialChecker_SkipsVerifierAfterBaseError(t *testing.T) { + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + checker := withAdmissionVerifier(context.Background(), staticCredentialChecker{err: handshake.ErrInvalidCredentials}, verifier, true, "network-1") + + _, err := checker.CheckCredential("peer-id", &handshakeproto.Credentials{}) + assert.Equal(t, handshake.ErrInvalidCredentials, err) + assert.Empty(t, verifier.Requests()) +} + func TestIncompatibleVersion(t *testing.T) { a1 := newTestAccData(t) a2 := newTestAccData(t) @@ -86,3 +197,39 @@ func newTestAccData(t *testing.T) *accountdata.AccountKeys { require.NoError(t, as.Init(nil)) return as.Account() } + +type recordingAdmissionVerifier struct { + mu sync.Mutex + requests []AdmissionRequest + decision AdmissionDecision + err error +} + +func (r *recordingAdmissionVerifier) VerifyAdmission(ctx context.Context, req AdmissionRequest) (AdmissionDecision, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.requests = append(r.requests, req) + return r.decision, r.err +} + +func (r *recordingAdmissionVerifier) Requests() []AdmissionRequest { + r.mu.Lock() + defer r.mu.Unlock() + requests := make([]AdmissionRequest, len(r.requests)) + copy(requests, r.requests) + return requests +} + +type staticCredentialChecker struct { + makeCred *handshakeproto.Credentials + result handshake.Result + err error +} + +func (s staticCredentialChecker) MakeCredentials(remotePeerId string) *handshakeproto.Credentials { + return s.makeCred +} + +func (s staticCredentialChecker) CheckCredential(remotePeerId string, cred *handshakeproto.Credentials) (handshake.Result, error) { + return s.result, s.err +} diff --git a/net/secureservice/handshake/credential_test.go b/net/secureservice/handshake/credential_test.go index fe6687a68..741cd763d 100644 --- a/net/secureservice/handshake/credential_test.go +++ b/net/secureservice/handshake/credential_test.go @@ -18,9 +18,10 @@ var noVerifyChecker = &testCredChecker{ makeCred: &handshakeproto.Credentials{Type: handshakeproto.CredentialsType_SkipVerify, ClientVersion: "test:v1.0"}, checkCred: func(peerId string, cred *handshakeproto.Credentials) (res Result, err error) { return Result{ - Identity: []byte("identity"), - ProtoVersion: cred.Version, - ClientVersion: cred.ClientVersion, + Identity: []byte("identity"), + ProtoVersion: cred.Version, + ClientVersion: cred.ClientVersion, + AdmissionToken: cred.AdmissionToken, }, nil }, } @@ -46,7 +47,10 @@ func TestOutgoingHandshake(t *testing.T) { _, err = noVerifyChecker.CheckCredential("p1", msg.cred) require.NoError(t, err) // send credential message - require.NoError(t, h.writeCredentials(noVerifyChecker.MakeCredentials(""))) + const remoteToken = "remote-admission-token" + remoteCred := *noVerifyChecker.MakeCredentials("") + remoteCred.AdmissionToken = remoteToken + require.NoError(t, h.writeCredentials(&remoteCred)) // receive ack msg, err = h.readMsg(msgTypeAck) require.NoError(t, err) @@ -55,6 +59,7 @@ func TestOutgoingHandshake(t *testing.T) { require.NoError(t, h.writeAck(handshakeproto.Error_Null)) res := <-handshakeResCh assert.NotEmpty(t, res.res) + assert.Equal(t, remoteToken, res.res.AdmissionToken) assert.NoError(t, res.err) }) t.Run("write cred err", func(t *testing.T) { @@ -233,6 +238,7 @@ func TestOutgoingHandshake(t *testing.T) { func TestIncomingHandshake(t *testing.T) { t.Run("success", func(t *testing.T) { c1, c2 := newConnPair(t) + const remoteToken = "remote-admission-token" var handshakeResCh = make(chan handshakeRes, 1) go func() { identity, err := IncomingHandshake(nil, c1, "", noVerifyChecker) @@ -241,7 +247,9 @@ func TestIncomingHandshake(t *testing.T) { h := newHandshake() h.conn = c2 // write credentials - require.NoError(t, h.writeCredentials(noVerifyChecker.MakeCredentials(""))) + remoteCred := *noVerifyChecker.MakeCredentials("") + remoteCred.AdmissionToken = remoteToken + require.NoError(t, h.writeCredentials(&remoteCred)) // wait credentials msg, err := h.readMsg(msgTypeCred) require.NoError(t, err) @@ -255,6 +263,7 @@ func TestIncomingHandshake(t *testing.T) { assert.Equal(t, handshakeproto.Error_Null, msg.ack.Error) res := <-handshakeResCh assert.NotEmpty(t, res.res) + assert.Equal(t, remoteToken, res.res.AdmissionToken) require.NoError(t, res.err) }) t.Run("write cred err", func(t *testing.T) { @@ -490,26 +499,39 @@ func TestEndToEnd(t *testing.T) { inResCh = make(chan handshakeRes, 1) outResCh = make(chan handshakeRes, 1) ) + clientChecker := cloneNoVerifyCheckerWithAdmissionToken("client-admission-token") + serverChecker := cloneNoVerifyCheckerWithAdmissionToken("server-admission-token") st := time.Now() go func() { - identity, err := OutgoingHandshake(nil, c1, "", noVerifyChecker) + identity, err := OutgoingHandshake(nil, c1, "", clientChecker) outResCh <- handshakeRes{res: identity, err: err} }() go func() { - identity, err := IncomingHandshake(nil, c2, "", noVerifyChecker) + identity, err := IncomingHandshake(nil, c2, "", serverChecker) inResCh <- handshakeRes{res: identity, err: err} }() outRes := <-outResCh assert.NoError(t, outRes.err) assert.NotEmpty(t, outRes.res) + assert.Equal(t, "server-admission-token", outRes.res.AdmissionToken) inRes := <-inResCh assert.NoError(t, inRes.err) assert.NotEmpty(t, inRes.res) + assert.Equal(t, "client-admission-token", inRes.res.AdmissionToken) t.Log("dur", time.Since(st)) } +func cloneNoVerifyCheckerWithAdmissionToken(token string) *testCredChecker { + cred := *noVerifyChecker.makeCred + cred.AdmissionToken = token + return &testCredChecker{ + makeCred: &cred, + checkCred: noVerifyChecker.checkCred, + } +} + func BenchmarkHandshake(b *testing.B) { c1, c2 := newConnPair(b) var ( diff --git a/net/secureservice/handshake/handshake.go b/net/secureservice/handshake/handshake.go index 845c8de76..5ec62fbfe 100644 --- a/net/secureservice/handshake/handshake.go +++ b/net/secureservice/handshake/handshake.go @@ -70,9 +70,10 @@ type CredentialChecker interface { } type Result struct { - Identity []byte - ProtoVersion uint32 - ClientVersion string + Identity []byte + ProtoVersion uint32 + ClientVersion string + AdmissionToken string } func newHandshake() *handshake { @@ -191,6 +192,7 @@ func (h *handshake) release() { h.remoteAck.Error = 0 h.remoteCred.Type = 0 h.remoteCred.Payload = h.remoteCred.Payload[:0] + h.remoteCred.AdmissionToken = "" h.remoteProto.Proto = 0 h.remoteProto.Encodings = h.remoteProto.Encodings[:0] handshakePool.Put(h) diff --git a/net/secureservice/handshake/handshakeproto/handshake.pb.go b/net/secureservice/handshake/handshakeproto/handshake.pb.go index c0322e8b2..8df89faff 100644 --- a/net/secureservice/handshake/handshakeproto/handshake.pb.go +++ b/net/secureservice/handshake/handshakeproto/handshake.pb.go @@ -223,13 +223,14 @@ func (Encoding) EnumDescriptor() ([]byte, []int) { } type Credentials struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type CredentialsType `protobuf:"varint,1,opt,name=type,proto3,enum=anyHandshake.CredentialsType" json:"type,omitempty"` - Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` - Version uint32 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` - ClientVersion string `protobuf:"bytes,4,opt,name=clientVersion,proto3" json:"clientVersion,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Type CredentialsType `protobuf:"varint,1,opt,name=type,proto3,enum=anyHandshake.CredentialsType" json:"type,omitempty"` + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` + Version uint32 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + ClientVersion string `protobuf:"bytes,4,opt,name=clientVersion,proto3" json:"clientVersion,omitempty"` + AdmissionToken string `protobuf:"bytes,5,opt,name=admissionToken,proto3" json:"admissionToken,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Credentials) Reset() { @@ -290,6 +291,13 @@ func (x *Credentials) GetClientVersion() string { return "" } +func (x *Credentials) GetAdmissionToken() string { + if x != nil { + return x.AdmissionToken + } + return "" +} + type PayloadSignedPeerIds struct { state protoimpl.MessageState `protogen:"open.v1"` // account identity @@ -444,12 +452,13 @@ var File_net_secureservice_handshake_handshakeproto_protos_handshake_proto proto const file_net_secureservice_handshake_handshakeproto_protos_handshake_proto_rawDesc = "" + "\n" + - "Anet/secureservice/handshake/handshakeproto/protos/handshake.proto\x12\fanyHandshake\"\x9a\x01\n" + + "Anet/secureservice/handshake/handshakeproto/protos/handshake.proto\x12\fanyHandshake\"\xc2\x01\n" + "\vCredentials\x121\n" + "\x04type\x18\x01 \x01(\x0e2\x1d.anyHandshake.CredentialsTypeR\x04type\x12\x18\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x18\n" + "\aversion\x18\x03 \x01(\rR\aversion\x12$\n" + - "\rclientVersion\x18\x04 \x01(\tR\rclientVersion\"F\n" + + "\rclientVersion\x18\x04 \x01(\tR\rclientVersion\x12&\n" + + "\x0eadmissionToken\x18\x05 \x01(\tR\x0eadmissionToken\"F\n" + "\x14PayloadSignedPeerIds\x12\x1a\n" + "\bidentity\x18\x01 \x01(\fR\bidentity\x12\x12\n" + "\x04sign\x18\x02 \x01(\fR\x04sign\"0\n" + diff --git a/net/secureservice/handshake/handshakeproto/handshake_vtproto.pb.go b/net/secureservice/handshake/handshakeproto/handshake_vtproto.pb.go index 55a1fe9ca..51ad5cbb3 100644 --- a/net/secureservice/handshake/handshakeproto/handshake_vtproto.pb.go +++ b/net/secureservice/handshake/handshakeproto/handshake_vtproto.pb.go @@ -48,6 +48,13 @@ func (m *Credentials) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.AdmissionToken) > 0 { + i -= len(m.AdmissionToken) + copy(dAtA[i:], m.AdmissionToken) + i = protohelpers.EncodeVarint(dAtA, i, uint64(len(m.AdmissionToken))) + i-- + dAtA[i] = 0x2a + } if len(m.ClientVersion) > 0 { i -= len(m.ClientVersion) copy(dAtA[i:], m.ClientVersion) @@ -239,6 +246,10 @@ func (m *Credentials) SizeVT() (n int) { if l > 0 { n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + l = len(m.AdmissionToken) + if l > 0 { + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } n += len(m.unknownFields) return n } @@ -427,6 +438,38 @@ func (m *Credentials) UnmarshalVT(dAtA []byte) error { } m.ClientVersion = string(dAtA[iNdEx:postIndex]) iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AdmissionToken", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AdmissionToken = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/net/secureservice/handshake/handshakeproto/protos/handshake.proto b/net/secureservice/handshake/handshakeproto/protos/handshake.proto index 624a13512..6fe435d57 100644 --- a/net/secureservice/handshake/handshakeproto/protos/handshake.proto +++ b/net/secureservice/handshake/handshakeproto/protos/handshake.proto @@ -40,6 +40,7 @@ message Credentials { bytes payload = 2; uint32 version = 3; string clientVersion = 4; + string admissionToken = 5; } enum CredentialsType { @@ -93,4 +94,4 @@ enum ProtoType { enum Encoding { None = 0; Snappy = 1; -} \ No newline at end of file +} diff --git a/net/secureservice/secureservice.go b/net/secureservice/secureservice.go index ab85933d2..2ec9ab149 100644 --- a/net/secureservice/secureservice.go +++ b/net/secureservice/secureservice.go @@ -50,6 +50,10 @@ func New() SecureService { return &secureService{} } +func NewWithAdmissionVerifier(verifier AdmissionVerifier) SecureService { + return &secureService{admissionVerifier: verifier} +} + type SecureService interface { SecureOutbound(ctx context.Context, conn net.Conn) (cctx context.Context, err error) HandshakeOutbound(ctx context.Context, conn io.ReadWriteCloser, peerId string) (cctx context.Context, err error) @@ -70,6 +74,11 @@ type secureService struct { noVerifyChecker handshake.CredentialChecker peerSignVerifier handshake.CredentialChecker inboundChecker handshake.CredentialChecker + + admissionVerifier AdmissionVerifier + admissionEnabled bool + admissionRequired bool + networkID string } func (s *secureService) Init(a *app.App) (err error) { @@ -91,6 +100,12 @@ func (s *secureService) Init(a *app.App) (err error) { } s.compatibleVersions = conf.CompatibleVersions } + admissionConf := conf.Admission.WithDefaults() + s.admissionEnabled = admissionConf.Enabled || admissionConf.Required + s.admissionRequired = admissionConf.Required + if s.admissionEnabled && s.admissionVerifier == nil { + return ErrAdmissionInvalidConfig + } peerKey, err := account.Account().PeerKey.Raw() if err != nil { @@ -103,10 +118,16 @@ func (s *secureService) Init(a *app.App) (err error) { s.peerSignVerifier = newPeerSignVerifier(s.protoVersion, s.compatibleVersions, a.VersionName(), account.Account()) s.nodeconf = a.MustComponent(nodeconf.CName).(nodeconf.Service) + if s.admissionEnabled { + s.networkID = s.nodeconf.Configuration().NetworkId + if s.networkID == "" { + return ErrAdmissionInvalidConfig + } + } s.inboundChecker = s.noVerifyChecker confTypes := s.nodeconf.NodeTypes(account.Account().PeerId) - if conf.RequireClientAuth || len(confTypes) > 0 { + if conf.RequireClientAuth || len(confTypes) > 0 || s.admissionRequired { // require identity verification if we are node s.inboundChecker = s.peerSignVerifier } @@ -134,7 +155,12 @@ func (s *secureService) SecureInbound(ctx context.Context, conn net.Conn) (cctx } func (s *secureService) HandshakeInbound(ctx context.Context, conn io.ReadWriteCloser, peerId string) (cctx context.Context, err error) { - res, err := handshake.IncomingHandshake(ctx, conn, peerId, s.inboundChecker) + checker := s.inboundChecker + if s.admissionEnabled { + checker = withAdmissionVerifier(ctx, checker, s.admissionVerifier, s.admissionRequired, s.networkID) + } + checker = withAdmissionToken(checker, CtxOutboundAdmissionToken(ctx)) + res, err := handshake.IncomingHandshake(ctx, conn, peerId, checker) if err != nil { return nil, err } @@ -143,6 +169,7 @@ func (s *secureService) HandshakeInbound(ctx context.Context, conn io.ReadWriteC cctx = peer.CtxWithIdentity(cctx, res.Identity) cctx = peer.CtxWithClientVersion(cctx, res.ClientVersion) cctx = peer.CtxWithProtoVersion(cctx, res.ProtoVersion) + cctx = ctxWithRemoteAdmissionToken(cctx, res.AdmissionToken) return } @@ -162,6 +189,7 @@ func (s *secureService) HandshakeOutbound(ctx context.Context, conn io.ReadWrite } else { checker = s.noVerifyChecker } + checker = withAdmissionToken(checker, CtxOutboundAdmissionToken(ctx)) res, err := handshake.OutgoingHandshake(ctx, conn, peerId, checker) if err != nil { return nil, err @@ -171,6 +199,7 @@ func (s *secureService) HandshakeOutbound(ctx context.Context, conn io.ReadWrite cctx = peer.CtxWithIdentity(cctx, res.Identity) cctx = peer.CtxWithClientVersion(cctx, res.ClientVersion) cctx = peer.CtxWithProtoVersion(cctx, res.ProtoVersion) + cctx = ctxWithRemoteAdmissionToken(cctx, res.AdmissionToken) return cctx, nil } diff --git a/net/secureservice/secureservice_test.go b/net/secureservice/secureservice_test.go index bc4dea2dc..7d5689fdc 100644 --- a/net/secureservice/secureservice_test.go +++ b/net/secureservice/secureservice_test.go @@ -39,7 +39,8 @@ func TestHandshake(t *testing.T) { fxC := newFixture(t, nc, nc.GetAccountService(1), 1, []uint32{1}) defer fxC.Finish(t) - cctx, err := fxC.SecureOutbound(ctx, cc) + const admissionToken = "client-admission-token" + cctx, err := fxC.SecureOutbound(CtxWithOutboundAdmissionToken(ctx, admissionToken), cc) require.NoError(t, err) ctxPeerId, err := peer.CtxPeerId(cctx) require.NoError(t, err) @@ -53,6 +54,88 @@ func TestHandshake(t *testing.T) { marshalledId, _ := nc.GetAccountService(1).Account().SignKey.GetPublic().Marshall() assert.Equal(t, nc.GetAccountService(1).Account().PeerId, peerId) assert.Equal(t, marshalledId, accId) + assert.Equal(t, admissionToken, CtxRemoteAdmissionToken(res.ctx)) + assert.Empty(t, CtxOutboundAdmissionToken(res.ctx)) +} + +func TestHandshakeAdmissionRequired(t *testing.T) { + nc := testnodeconf.GenNodeConfig(2) + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + secureConf := Config{Admission: AdmissionConfig{Enabled: true, Required: true}} + fxS := newFixtureWithSecureConfig(t, nc, nc.GetAccountService(0), 1, []uint32{1}, &secureConf, verifier) + defer fxS.Finish(t) + sc, cc := net.Pipe() + + type acceptRes struct { + ctx context.Context + err error + } + resCh := make(chan acceptRes) + go func() { + var ar acceptRes + ar.ctx, ar.err = fxS.SecureInbound(ctx, sc) + resCh <- ar + }() + + fxC := newFixture(t, nc, nc.GetAccountService(1), 1, []uint32{1}) + defer fxC.Finish(t) + + const admissionToken = "client-admission-token" + _, err := fxC.SecureOutbound(CtxWithOutboundAdmissionToken(ctx, admissionToken), cc) + require.NoError(t, err) + res := <-resCh + require.NoError(t, res.err) + assert.Equal(t, admissionToken, CtxRemoteAdmissionToken(res.ctx)) + + requests := verifier.Requests() + require.Len(t, requests, 1) + assert.Equal(t, admissionToken, requests[0].Token) + assert.Equal(t, "test-network", requests[0].NetworkID) + assert.Equal(t, nc.GetAccountService(1).Account().PeerId, requests[0].PeerID) + assert.NotEmpty(t, requests[0].Identity) +} + +func TestHandshakeAdmissionRequiredRejectsMissingToken(t *testing.T) { + nc := testnodeconf.GenNodeConfig(2) + verifier := &recordingAdmissionVerifier{ + decision: AdmissionDecision{Allowed: true, Reason: "ok"}, + } + secureConf := Config{Admission: AdmissionConfig{Enabled: true, Required: true}} + fxS := newFixtureWithSecureConfig(t, nc, nc.GetAccountService(0), 1, []uint32{1}, &secureConf, verifier) + defer fxS.Finish(t) + sc, cc := net.Pipe() + + type acceptRes struct { + err error + } + resCh := make(chan acceptRes) + go func() { + var ar acceptRes + _, ar.err = fxS.SecureInbound(ctx, sc) + resCh <- ar + }() + + fxC := newFixture(t, nc, nc.GetAccountService(1), 1, []uint32{1}) + defer fxC.Finish(t) + + _, err := fxC.SecureOutbound(ctx, cc) + assert.Equal(t, handshake.ErrPeerDeclinedCredentials, err) + res := <-resCh + assert.Equal(t, handshake.ErrInvalidCredentials, res.err) + assert.Empty(t, verifier.Requests()) +} + +func TestInitAdmissionEnabledRequiresVerifier(t *testing.T) { + nc := testnodeconf.GenNodeConfig(1) + secureConf := Config{Admission: AdmissionConfig{Enabled: true}} + a := new(app.App) + a.Register(nc.GetAccountService(0)).Register(testSecureConfig{nodeConf: nc, secureConf: secureConf}) + + ss := New().(*secureService) + err := ss.Init(a) + assert.ErrorIs(t, err, ErrAdmissionInvalidConfig) } func TestHandshakeIncompatibleVersion(t *testing.T) { @@ -81,11 +164,19 @@ func TestHandshakeIncompatibleVersion(t *testing.T) { } func newFixture(t *testing.T, nc *testnodeconf.Config, acc accountservice.Service, protoVersion uint32, cv []uint32) *fixture { + return newFixtureWithSecureConfig(t, nc, acc, protoVersion, cv, nil, nil) +} + +func newFixtureWithSecureConfig(t *testing.T, nc *testnodeconf.Config, acc accountservice.Service, protoVersion uint32, cv []uint32, secureConf *Config, admissionVerifier AdmissionVerifier) *fixture { fx := &fixture{ - ctrl: gomock.NewController(t), - secureService: New().(*secureService), - acc: acc, - a: new(app.App), + ctrl: gomock.NewController(t), + acc: acc, + a: new(app.App), + } + if admissionVerifier != nil { + fx.secureService = NewWithAdmissionVerifier(admissionVerifier).(*secureService) + } else { + fx.secureService = New().(*secureService) } fx.secureService.protoVersion = protoVersion fx.secureService.compatibleVersions = cv @@ -95,11 +186,29 @@ func newFixture(t *testing.T, nc *testnodeconf.Config, acc accountservice.Servic fx.mockNodeConf.EXPECT().Run(ctx) fx.mockNodeConf.EXPECT().Close(ctx) fx.mockNodeConf.EXPECT().NodeTypes(gomock.Any()).Return([]nodeconf.NodeType{nodeconf.NodeTypeTree}).AnyTimes() - fx.a.Register(fx.acc).Register(nc).Register(fx.mockNodeConf).Register(fx.secureService) + fx.mockNodeConf.EXPECT().Configuration().Return(nodeconf.Configuration{NetworkId: "test-network"}).AnyTimes() + configComponent := app.Component(nc) + if secureConf != nil { + configComponent = testSecureConfig{nodeConf: nc, secureConf: *secureConf} + } + fx.a.Register(fx.acc).Register(configComponent).Register(fx.mockNodeConf).Register(fx.secureService) require.NoError(t, fx.a.Start(ctx)) return fx } +type testSecureConfig struct { + nodeConf *testnodeconf.Config + secureConf Config +} + +func (t testSecureConfig) Init(a *app.App) error { return nil } + +func (t testSecureConfig) Name() string { return "config" } + +func (t testSecureConfig) GetSecureService() Config { return t.secureConf } + +func (t testSecureConfig) GetNodeConf() nodeconf.Configuration { return t.nodeConf.GetNodeConf() } + type fixture struct { *secureService a *app.App