Adding templates to scoped signing key user permis (#3367)

For security reasons we have introduced scoped signing keys to jwt.
They carry user permissions.
Wich is why jwt issued by those keys are not allowed to carry their own permission.
Instead they are copied from the signing key.
If the scoped signing key gets compromised, an attacker can only issue jwt with the permissions of the key.
With a plain signing key, an attacker can create arbitrary user with permissions.
Because user jwt creation is greatly simplified we added a single utility function to go/java/.net which issues user for such keys.
This is function is documented in ADR-14:

```
/**
 * signingKey, is a mandatory account nkey pair to sign the generated jwt.
 * accountId, is a mandatory public account nkey. Will return error when not set or not account nkey.
 * publicUserKey, is a mandatory public user nkey. Will return error when not set or not user nkey.
 * name, optional human readable name. When absent, default to publicUserKey.
 * expiration, optional but recommended duration, when the generated jwt needs to expire. If not set, JWT will not expire.
 * tags, optional list of tags to be included in the JWT.
 *
 * Returns:
 * error, when issues arose.
 * string, resulting jwt.
 **/
IssueUserJWT(signingKey nkey, accountId string, publicUserKey string, name string, expiration time.Duration, tags []string) (error, string)
```

Currently the only downside of this is that the permissions are static and can't be tailored to the user.

This PR changes that by allowing the user pub/sub permissions to be parameterized with templates.

templates are for entire tokens only and include:
{{name()}} -> username
{{subject()}} -> user subject (nkey)
{{account-name()}} -> users account name
{{account-subject()}} -> user accoutn subject (nkey)

{{tag(arbitrary-prefix)}}
provided the tag "arbitrary-prefix:value" will result in "value"
provided the tags ["arbitrary-prefix:1", "arbitrary-prefix:2"] will result in two subjects "1" & "2"

If the resulting subject is not valid.
Say a tag is not present or name is not set.
This will result in an error for deny subjects
and result in no subject for allow subject.

Signed-off-by: Matthias Hanel <mh@synadia.com>

Signed-off-by: Matthias Hanel <mh@synadia.com>
This commit is contained in:
Matthias Hanel
2022-08-15 12:49:35 -07:00
committed by GitHub
parent 9e748ed2e7
commit b7ee177497
2 changed files with 271 additions and 2 deletions

View File

@@ -371,6 +371,161 @@ func (c *client) matchesPinnedCert(tlsPinnedCerts PinnedCertSet) bool {
return true
}
func processUserPermissionsTemplate(lim jwt.UserPermissionLimits, ujwt *jwt.UserClaims, acc *Account) (jwt.UserPermissionLimits, error) {
nArrayCartesianProduct := func(a ...[]string) [][]string {
c := 1
for _, a := range a {
c *= len(a)
}
if c == 0 {
return nil
}
p := make([][]string, c)
b := make([]string, c*len(a))
n := make([]int, len(a))
s := 0
for i := range p {
e := s + len(a)
pi := b[s:e]
p[i] = pi
s = e
for j, n := range n {
pi[j] = a[j][n]
}
for j := len(n) - 1; j >= 0; j-- {
n[j]++
if n[j] < len(a[j]) {
break
}
n[j] = 0
}
}
return p
}
applyTemplate := func(list jwt.StringList, failOnBadSubject bool) (jwt.StringList, error) {
found := false
FOR_FIND:
for i := 0; i < len(list); i++ {
// check if templates are present
for _, tk := range strings.Split(list[i], tsep) {
if strings.HasPrefix(tk, "{{") && strings.HasSuffix(tk, "}}") {
found = true
break FOR_FIND
}
}
}
if !found {
return list, nil
}
// process the templates
emittedList := make([]string, 0, len(list))
for i := 0; i < len(list); i++ {
tokens := strings.Split(list[i], tsep)
newTokens := make([]string, len(tokens))
tagValues := map[int][]string{} // indexed by token
for tokenNum, tk := range tokens {
if strings.HasPrefix(tk, "{{") && strings.HasSuffix(tk, "}}") {
op := strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(tk, "{{"), "}}"))
switch {
case op == "name()":
tk = ujwt.Name
case op == "subject()":
tk = ujwt.Subject
case op == "account-name()":
acc.mu.RLock()
name := acc.nameTag
acc.mu.RUnlock()
tk = name
case op == "account-subject()":
tk = ujwt.IssuerAccount
case (strings.HasPrefix(op, "tag(") || strings.HasPrefix(op, "account-tag(")) &&
strings.HasSuffix(op, ")"):
// insert dummy tav value that will throw of subject validation (in case nothing is found)
tk = _EMPTY_
// collect list of matching tag values
var tags jwt.TagList
var tagPrefix string
if strings.HasPrefix(op, "account-tag(") {
acc.mu.RLock()
tags = acc.tags
acc.mu.RUnlock()
tagPrefix = fmt.Sprintf("%s:", strings.ToLower(
strings.TrimSuffix(strings.TrimPrefix(op, "account-tag("), ")")))
} else {
tags = ujwt.Tags
tagPrefix = fmt.Sprintf("%s:", strings.ToLower(
strings.TrimSuffix(strings.TrimPrefix(op, "tag("), ")")))
}
for _, tag := range tags {
if strings.HasPrefix(tag, tagPrefix) {
tagValue := strings.TrimPrefix(tag, tagPrefix)
tagValues[tokenNum] = append(tagValues[tokenNum], tagValue)
}
}
default:
// if macro is not recognized, throw off subject check on purpose
tk = " "
}
}
newTokens[tokenNum] = tk
}
// fill in tag value placeholders
if len(tagValues) == 0 {
emitSubj := strings.Join(newTokens, tsep)
if IsValidSubject(emitSubj) {
emittedList = append(emittedList, emitSubj)
} else if failOnBadSubject {
return nil, fmt.Errorf("generated invalid subject")
}
// else skip emitting
} else {
orderedList := make([][]string, 0, len(tagValues))
for _, valueList := range tagValues {
orderedList = append(orderedList, valueList)
}
// compute the cartesian product and compute subject to emit for each combination
for _, valueList := range nArrayCartesianProduct(orderedList...) {
b := strings.Builder{}
for i, token := range newTokens {
if token == _EMPTY_ {
b.WriteString(valueList[0])
valueList = valueList[1:]
} else {
b.WriteString(token)
}
if i != len(newTokens)-1 {
b.WriteString(tsep)
}
}
emitSubj := b.String()
if IsValidSubject(emitSubj) {
emittedList = append(emittedList, emitSubj)
} else if failOnBadSubject {
return nil, fmt.Errorf("generated invalid subject")
}
// else skip emitting
}
}
}
return emittedList, nil
}
var err error
if lim.Permissions.Sub.Allow, err = applyTemplate(lim.Permissions.Sub.Allow, false); err != nil {
return jwt.UserPermissionLimits{}, err
} else if lim.Permissions.Sub.Deny, err = applyTemplate(lim.Permissions.Sub.Deny, true); err != nil {
return jwt.UserPermissionLimits{}, err
} else if lim.Permissions.Pub.Allow, err = applyTemplate(lim.Permissions.Pub.Allow, false); err != nil {
return jwt.UserPermissionLimits{}, err
} else if lim.Permissions.Pub.Deny, err = applyTemplate(lim.Permissions.Pub.Deny, true); err != nil {
return jwt.UserPermissionLimits{}, err
}
return lim, nil
}
func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) bool {
var (
nkey *NkeyUser
@@ -621,8 +776,9 @@ func (s *Server) processClientOrLeafAuthentication(c *client, opts *Options) boo
} else if uSc, ok := scope.(*jwt.UserScope); !ok {
c.Debugf("User JWT is not valid")
return false
} else {
juc.UserPermissionLimits = uSc.Template
} else if juc.UserPermissionLimits, err = processUserPermissionsTemplate(uSc.Template, juc, acc); err != nil {
c.Debugf("User JWT is not valid")
return false
}
}
if acc.IsExpired() {

View File

@@ -4163,6 +4163,119 @@ func TestJWTLimits(t *testing.T) {
})
}
func TestJwtTemplates(t *testing.T) {
kp, _ := nkeys.CreateAccount()
aPub, _ := kp.PublicKey()
ukp, _ := nkeys.CreateUser()
upub, _ := ukp.PublicKey()
uclaim := newJWTTestUserClaims()
uclaim.Name = "myname"
uclaim.Subject = upub
uclaim.SetScoped(true)
uclaim.IssuerAccount = aPub
uclaim.Tags.Add("foo:foo1")
uclaim.Tags.Add("foo:foo2")
uclaim.Tags.Add("bar:bar1")
uclaim.Tags.Add("bar:bar2")
uclaim.Tags.Add("bar:bar3")
lim := jwt.UserPermissionLimits{}
lim.Pub.Allow.Add("{{tag(foo)}}.none.{{tag(bar)}}")
lim.Pub.Deny.Add("{{tag(foo)}}.{{account-tag(acc)}}")
lim.Sub.Allow.Add("{{tag(NOT_THERE)}}") // expect to not emit this
lim.Sub.Deny.Add("foo.{{name()}}.{{subject()}}.{{account-name()}}.{{account-subject()}}.bar")
acc := &Account{nameTag: "accname", tags: []string{"acc:acc1", "acc:acc2"}}
resLim, err := processUserPermissionsTemplate(lim, uclaim, acc)
require_NoError(t, err)
test := func(expectedSubjects []string, res jwt.StringList) {
t.Helper()
require_True(t, len(res) == len(expectedSubjects))
for _, expetedSubj := range expectedSubjects {
require_True(t, res.Contains(expetedSubj))
}
}
test(resLim.Pub.Allow, []string{"foo1.none.bar1", "foo1.none.bar2", "foo1.none.bar3",
"foo2.none.bar1", "foo2.none.bar2", "foo2.none.bar3"})
test(resLim.Pub.Deny, []string{"foo1.acc1", "foo1.acc2", "foo2.acc1", "foo2.acc2"})
require_True(t, len(resLim.Sub.Allow) == 0)
require_True(t, len(resLim.Sub.Deny) == 1)
require_Contains(t, resLim.Sub.Deny[0], fmt.Sprintf("foo.myname.%s.accname.%s.bar", upub, aPub))
}
func TestJWTLimitsTemplate(t *testing.T) {
kp, _ := nkeys.CreateAccount()
aPub, _ := kp.PublicKey()
claim := jwt.NewAccountClaims(aPub)
aSignScopedKp, aSignScopedPub := createKey(t)
signer := jwt.NewUserScope()
signer.Key = aSignScopedPub
signer.Template.Pub.Deny.Add("denied")
signer.Template.Pub.Allow.Add("foo.{{name()}}")
signer.Template.Sub.Allow.Add("foo.{{name()}}")
claim.SigningKeys.AddScopedSigner(signer)
aJwt, err := claim.Encode(oKp)
require_NoError(t, err)
conf := createConfFile(t, []byte(fmt.Sprintf(`
listen: 127.0.0.1:-1
operator: %s
resolver: MEM
resolver_preload: {
%s: %s
}
`, ojwt, aPub, aJwt)))
defer removeFile(t, conf)
sA, _ := RunServerWithConfig(conf)
defer sA.Shutdown()
errChan := make(chan struct{})
defer close(errChan)
ukp, _ := nkeys.CreateUser()
seed, _ := ukp.Seed()
upub, _ := ukp.PublicKey()
uclaim := newJWTTestUserClaims()
uclaim.Name = "myname"
uclaim.Subject = upub
uclaim.SetScoped(true)
uclaim.IssuerAccount = aPub
ujwt, err := uclaim.Encode(aSignScopedKp)
require_NoError(t, err)
creds := genCredsFile(t, ujwt, seed)
defer removeFile(t, creds)
t.Run("pass", func(t *testing.T) {
c := natsConnect(t, sA.ClientURL(), nats.UserCredentials(creds))
defer c.Close()
sub, err := c.SubscribeSync("foo.myname")
require_NoError(t, err)
require_NoError(t, c.Flush())
require_NoError(t, c.Publish("foo.myname", nil))
_, err = sub.NextMsg(time.Second)
require_NoError(t, err)
})
t.Run("fail", func(t *testing.T) {
c := natsConnect(t, sA.ClientURL(), nats.UserCredentials(creds),
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
if strings.Contains(err.Error(), `nats: Permissions Violation for Publish to "foo.othername"`) {
errChan <- struct{}{}
}
}))
defer c.Close()
require_NoError(t, c.Publish("foo.othername", nil))
select {
case <-errChan:
case <-time.After(time.Second * 2):
require_True(t, false)
}
})
}
func TestJWTNoOperatorMode(t *testing.T) {
for _, login := range []bool{true, false} {
t.Run("", func(t *testing.T) {