Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(examples): auth pattern exploration #3406

Draft
wants to merge 39 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
d1f0fce
feat: auth pattern
Dec 25, 2024
8ba0626
chore: mod tidy
Dec 26, 2024
f0b169b
chore: add core auth test
Dec 26, 2024
095aa6a
chore: simplify test and add doc
Dec 26, 2024
ea3e5df
chore: more doc
Dec 26, 2024
39a78ae
chore: use test realm
Dec 26, 2024
f564760
chore: more explicit example in test
Dec 26, 2024
322bfa7
chore: rename arg
Dec 26, 2024
c479c9c
chore: improve integration test and expose subacc.EntityID
Dec 26, 2024
60fc391
chore: improve integration test
Dec 26, 2024
9012eec
chore: improve test
Dec 26, 2024
2903fcc
chore: better comments
Dec 26, 2024
1d0ee2b
chore: cleaner
Dec 26, 2024
9da86f1
chore: authbanker doc
Dec 26, 2024
2a70395
chore: add check
Dec 26, 2024
6195e49
chore: move integration test
n0izn0iz Dec 26, 2024
1490451
chore: fix package path
n0izn0iz Dec 26, 2024
ef0b843
chore: add nil test
n0izn0iz Dec 26, 2024
b1b68aa
chore: explicitely handle nil tokens
n0izn0iz Dec 26, 2024
9a31a60
fix: vulnerability in authreg
n0izn0iz Dec 27, 2024
ab6c3ac
chore: add authreg adversarial test
n0izn0iz Dec 27, 2024
024c5e2
chore: add authbanker txtar
n0izn0iz Dec 27, 2024
79e39d2
chore: improve txtar spacing
n0izn0iz Dec 27, 2024
6a39e9a
Merge branch 'master' into auth-patterns
n0izn0iz Dec 28, 2024
ce9ea3c
fix: prevent vuln in subacc + cleanup
n0izn0iz Dec 28, 2024
de7d7b2
chore: fix test
n0izn0iz Dec 28, 2024
607cbef
chore: rename var
n0izn0iz Dec 28, 2024
4b8daf4
feat: unregister
n0izn0iz Dec 28, 2024
129acbf
chore: improve reg
n0izn0iz Dec 28, 2024
faa0df8
chore: disable check due to linter bug
n0izn0iz Dec 28, 2024
9108640
fix: prevent nil in subacc
n0izn0iz Dec 28, 2024
1743373
chore: moar doc
n0izn0iz Dec 28, 2024
651ab8d
chore: add sessions example
n0izn0iz Dec 28, 2024
e9f39fb
chore: remove dev artifact
n0izn0iz Dec 28, 2024
68b6e6b
fix: test
n0izn0iz Dec 28, 2024
e3d5f78
chore: add sessions.EntityID helper
n0izn0iz Dec 28, 2024
7c835b4
chore: abstract account
n0izn0iz Dec 28, 2024
eae7282
chore: clearer txtar comments
n0izn0iz Dec 28, 2024
23e45a3
chore: explicit comments
n0izn0iz Dec 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions examples/gno.land/p/demo/auth/auth.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Package auth provides object-based authentication interfaces and helpers.
package auth

import (
"errors"
"path"
"std"
"strings"

"gno.land/p/demo/ufmt"
)

// Token represents an authentication token
type Token interface {
// Source is used to identify the provenance of the token.
// It is intented to be used to find the corresponding [AuthenticateFn], for example in registries.
// It can be spoofed and should not be trusted.
Source() std.Realm
}

// AuthenticateFn validates a token and returns the ID of the authenticated entity.
// It panics with [ErrInvalidToken] if the token is invalid.
//
// XXX: Should we add some kind of `Token.EntityID` method instead of returning the ID here?
type AuthenticateFn = func(autok Token) string

var (
ErrInvalidToken = errors.New("invalid token")
)

// NamespacedEntityID generates safe entity IDs from a namespace and a sub-path.
// subPath can be user-provided and will be sanitized to prevent overriding the namespace.
func NamespacedEntityID(namespace, subPath string) string {
subPath = path.Clean(subPath)
if strings.HasPrefix(subPath, "..") {
panic(ufmt.Errorf("invalid sub-path %q", subPath))
}
return path.Join("/", namespace, subPath)
}
119 changes: 119 additions & 0 deletions examples/gno.land/p/demo/auth/auth_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package auth_test

import (
"std"
"testing"

"gno.land/p/demo/auth"
"gno.land/p/demo/urequire"
)

func TestToken(t *testing.T) {
urequire.NotPanics(t, func() {
authenticate(getToken())
})

urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() {
authenticate((*token)(nil))
})

urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() {
authenticate(getFakeToken())
})

urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() {
authenticate(nil)
})

urequire.PanicsWithMessage(t, auth.ErrInvalidToken.Error(), func() {
authenticate((*fakeToken)(nil))
})
}

var testRealm = std.NewCodeRealm("gno.land/r/demo/absacc")

type token struct {
}

func (t *token) Source() std.Realm {
return testRealm
}

var _ auth.Token = (*token)(nil)

func getToken() auth.Token {
return &token{}
}

func authenticate(autok auth.Token) string {
// the next line is the core of the auth pattern, this ensures we created this token
if val, ok := autok.(*token); !ok || val == nil {
panic(auth.ErrInvalidToken)
}

return "alice"
}

var _ auth.AuthenticateFn = authenticate

type fakeToken struct {
}

func (t *fakeToken) Source() std.Realm {
return testRealm
}

var _ auth.Token = (*fakeToken)(nil)

func getFakeToken() auth.Token {
return &fakeToken{}
}

func TestEntityID(t *testing.T) {
cases := []struct {
name string
namespace string
subPath string
res string
panicMessage string
}{
{
name: "good",
namespace: "alice",
subPath: "savings",
res: "/alice/savings",
},
{
name: "mal_backtrack",
namespace: "eve",
subPath: "../alice/savings",
panicMessage: `invalid sub-path "../alice/savings"`,
},
{
name: "mal_backtrack_dumb",
namespace: "eve",
subPath: "/../alice/savings",
res: "/eve/alice/savings",
},
{
name: "mal_backtrack_hidden",
namespace: "eve",
subPath: "todobien/very/deep/../../../../alice/savings",
panicMessage: `invalid sub-path "../alice/savings"`,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
run := func() {
res := auth.NamespacedEntityID(tc.namespace, tc.subPath)
urequire.Equal(t, tc.res, res)
}
if tc.panicMessage != "" {
urequire.PanicsWithMessage(t, tc.panicMessage, run)
} else {
urequire.NotPanics(t, run)
}
})
}
}
1 change: 1 addition & 0 deletions examples/gno.land/p/demo/auth/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/demo/auth
145 changes: 145 additions & 0 deletions examples/gno.land/r/demo/absacc/absacc.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package absacc

import (
"errors"
"std"

"gno.land/p/demo/auth"
"gno.land/p/demo/seqid"
"gno.land/p/demo/ufmt"
"gno.land/r/demo/authreg"
"gno.land/r/demo/sessions"
)

var (
source std.Realm
id seqid.ID
accounts = make(map[seqid.ID]*account)
)

func init() {
source = std.CurrentRealm()
authreg.Register(Authenticate)
}

func CreateCallerAccount() seqid.ID {
authorities := []Authority{
{
Provider: std.DerivePkgAddr("gno.land/r/demo/sessions"),
EntityID: sessions.EntityID(std.PrevRealm().Addr()),
},
}
return CreateAccount(authorities...)
}

func CreateAccount(authorities ...Authority) seqid.ID {
if len(authorities) == 0 {
panic(errors.New("must provide at least one authority, otherwise the account is locked"))
}

accountID := id.Next()
acc := account{
authorities: make(map[std.Address]map[string]struct{}),
}
acc.addAuthorities(authorities...)

accounts[accountID] = &acc
return accountID
}

func AddAuthorities(autok auth.Token, authorities ...Authority) {
account := authenticateAccount(autok)
account.addAuthorities(authorities...)
}

func RemoveAuthorities(autok auth.Token, authorities ...Authority) {
account := authenticateAccount(autok)
account.removeAuthorities(authorities...)
}

func AuthToken(accountID seqid.ID, subToken auth.Token) auth.Token {
if _, ok := accounts[accountID]; !ok {
panic(errors.New("unknown account"))
}
return &token{
accountID: accountID,
subToken: subToken,
}
}

func Authenticate(autok auth.Token) string {
return ufmt.Sprintf("%d", uint64(authenticateID(autok)))
}

func EntityID(accountID seqid.ID) string {
return ufmt.Sprintf("/%s/%d", source.Addr().String(), uint64(accountID))
}

func authenticateID(autok auth.Token) seqid.ID {
val, ok := autok.(*token)
if !ok || val == nil {
panic(auth.ErrInvalidToken)
}

entityID := authreg.Authenticate(val.subToken)
provider := val.subToken.Source().Addr()
if _, ok := accounts[val.accountID].authorities[provider][entityID]; !ok {
panic(auth.ErrInvalidToken)
}

return val.accountID
}

func authenticateAccount(autok auth.Token) *account {
return accounts[authenticateID(autok)]
}

type Authority struct {
Provider std.Address
EntityID string
}

type token struct {
accountID seqid.ID
subToken auth.Token
}

func (t *token) Source() std.Realm {
return source
}

type account struct {
authorities map[std.Address]map[string]struct{}
}

func (a *account) addAuthorities(authorities ...Authority) {
for _, authority := range authorities {
if _, ok := a.authorities[authority.Provider]; !ok {
a.authorities[authority.Provider] = make(map[string]struct{})
}
a.authorities[authority.Provider][authority.EntityID] = struct{}{}
}
}

func (a *account) removeAuthorities(authorities ...Authority) {
for _, authority := range authorities {
if _, ok := a.authorities[authority.Provider]; !ok {
continue
}
if authority.EntityID == "" {
delete(a.authorities, authority.Provider)
continue
}
if _, ok := a.authorities[authority.Provider][authority.EntityID]; !ok {
continue
}
if len(a.authorities[authority.Provider]) == 1 {
delete(a.authorities, authority.Provider)
} else {
delete(a.authorities[authority.Provider], authority.EntityID)
}
}
if len(a.authorities) == 1 {
panic(errors.New("must keep at least one authority, otherwise the account will be locked"))
}
}
1 change: 1 addition & 0 deletions examples/gno.land/r/demo/absacc/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/demo/absacc
80 changes: 80 additions & 0 deletions examples/gno.land/r/demo/authbanker/authbanker.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Package authbanker provide an example auth-consumer allowing to manipulate coins with auth tokens.
//
// It is quite limited, it only supports ugnots and only EOAs can fund accounts.
//
// XXX: support grc20 to allow for realm-realm interactions
package authbanker

import (
"errors"
"std"
"strings"

"gno.land/p/demo/auth"
"gno.land/p/demo/ufmt"
"gno.land/r/demo/authreg"
)

const denom = "ugnot"

var vaults = make(map[string]int64)

// GetCoins returns the amount of coins in the vault identified by `entityID`
func GetCoins(entityID string) int64 {
return vaults[entityID]
}

// SendCoins sends `amount` coins from the vault identified by `atok` to the account identified by `to`.
//
// `to` can be an entity ID or an address.
func SendCoins(atok auth.Token, to string, amount int64) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you write a txtar or share a few maketx calls that you expect to use with this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an integration test here

Copy link
Contributor Author

@n0izn0iz n0izn0iz Dec 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also added txtar here

if amount < 1 {
panic("sent amount must be >= 0")
}

from := authreg.Authenticate(atok)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What prevents me from creating my own authenticator? I don't understand where you expect to whitelist the approved authenticators.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing, the pattern is meant to be extendable.

The registry namespaces the authenticators so you can't authenticate an entity from an other authenticator.

How would you exploit this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually there was a vulnerability in namespacing if you pass paths with .., maybe you were refering to that? I added a guard against that here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also if you want to whitelist providers somewhere, you can check the token's Source or statically import the providers and call their Authenticate function

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was considering creating a new authenticator, registering it, and then using it for universal acceptance.

I see two secure approaches: one is to implement a whitelist system on the registry itself, and the other is to establish a whitelist system within the contract that checks the authentication.

However, allowing anyone to create an authenticator and contracts to simply "verify if a token is valid" is definitely insecure.


if from == to {
panic("cannot send to self")
}
if vaultAmount := vaults[from]; amount > vaultAmount {
panic(ufmt.Errorf("not enough in account %q", from))
}

vaults[from] -= amount

if isEntityID(to) {
vaults[to] += amount
} else {
realmBanker := std.GetBanker(std.BankerTypeRealmSend)
from := std.CurrentRealm().Addr()
coins := std.Coins{std.NewCoin(denom, amount)}
realmBanker.SendCoins(from, std.Address(to), coins)
}
}

// FundVault funds the vault identified by `entityID` with the `OrigSend` coins.
// It panics if it's not an `OriginCall`.
func FundVault(entityID string) {
// XXX: maybe replace the following with `authreg.Validate(to)`
if !isEntityID(entityID) {
panic("invalid destination")
}

std.AssertOriginCall()

sentCoins := std.GetOrigSend()
for _, coin := range sentCoins {
if coin.Denom != denom {
panic(ufmt.Errorf("only %q supported", denom))
}
if coin.Amount <= 0 {
panic(errors.New("send amount must be > 0"))
}
vaults[entityID] += coin.Amount
}
}

func isEntityID(str string) bool {
return strings.HasPrefix(str, "/")
}
1 change: 1 addition & 0 deletions examples/gno.land/r/demo/authbanker/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/demo/authbanker
Loading
Loading