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

refactor(gnovm): make IsOriginCall/AssertOriginCall rely on PrevRealm #1048

Closed
wants to merge 20 commits into from

Conversation

tbruyelle
Copy link
Contributor

@tbruyelle tbruyelle commented Aug 11, 2023

Now that PrevRealm works on tests files (#896), we can update IsOriginCall and AssertOriginCall so they rely on the same algorithm.

  • IsOriginCall returns true if the previous caller is the one that signed the tx, aka the original caller.
  • AssertOriginCall triggers a panic is IsOriginCall return false.

Initially, those 2 functions were using the number of frames of the Machine to determine if the previous caller was the original caller. In production, if the number of frames is 2, then IsOriginCall returns true. Also, in test environment, they had to be overrided because the number of frames is different than production (and also different between *_test.gno and *_filetest.gno files).

Using the number of frames is kinda weak, so I propose to rely on the PrevRealm algoirthm instead. Because by definition, if PrevRealm returns the original caller, that means the previous caller is the original caller.

List of changes:

  • moves the PrevRealm algorithm out of DefineNative() into a standalone function, so it can be easily tested and reused.
  • introduce isOriginCall standalone function that call PrevRealm.
  • use standalone functions isOriginCall and prevRealm in DefineNative()
  • remove test override of IsOriginCall and AssertOriginCall, because they're no longer needed thanks to the fact they are not relying on the number of frames any more.

Required additional fix:

  • In r/demo/boards, the *_filetest.gno files were badly configured, so I had to update most of them :
- // PKGPATH: gno.land/r/boards_test
- package boards_test
+ package main

Considering all r/demo/boards public functions start with AssertOriginCall, the *_filetest.gno files cannot introduce a secondary realm like gno.land/r/boards_test because else that means the previous caller isn't the original caller. By removing that secondary realm from the frames, we ensure the previous caller is the original one.

Contributors' checklist...
  • Added new tests, or not needed, or not feasible
  • Provided an example (e.g. screenshot) to aid review or the PR is self-explanatory
  • Updated the official documentation or not needed
  • No breaking changes were made, or a BREAKING CHANGE: xxx message was included in the description
  • Added references to related issues and PRs
  • Provided any useful hints for running manual tests
  • Added new benchmarks to generated graphs, if any. More info here.

@tbruyelle tbruyelle requested review from jaekwon, moul and a team as code owners August 11, 2023 14:38
@github-actions github-actions bot added 🧾 package/realm Tag used for new Realms or Packages. 📦 🤖 gnovm Issues or PRs gnovm related labels Aug 11, 2023
@tbruyelle tbruyelle changed the title refac(gnovm): make IsOriginCall/AssertOriginCall rely on PrevRealm refactor(gnovm): make IsOriginCall/AssertOriginCall rely on PrevRealm Aug 11, 2023
t.Errorf("expected IsOriginCall=false but got true")
}
AssertOriginCall()
}()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

No longer need to add an other frame (the function literal) to cheat the AssertOriginCall algorithm, since it doesn't use the number of frames any more.

addr: m.Context.(ExecContext).OrigCaller,
pkgPath: "", // empty for users
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having a standalone function also improves the readability of the algorithm, which used to be quite confusing IMO :)

@tbruyelle
Copy link
Contributor Author

tbruyelle commented Aug 11, 2023

r/demo/boards/z_4_filetest.gno is failing because of #1036 ... I don't really understand why this is happening here and not in this other PR #1023 ... Probably because it's no longer a realm in this PR, while it's still one in the other, which affects the content of the Realm instruction I assume.

Copy link
Member

@zivkovicmilos zivkovicmilos left a comment

Choose a reason for hiding this comment

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

Thank you for providing a detailed context in the PR description, as well as ample explanations in code 🙏

I was able to follow along easily and understand the motivation behind the changes.

Looks good 💯

@zivkovicmilos
Copy link
Member

Hey @tbruyelle,

Can you check why the CI is acting freaky with this PR?

@harry-hov harry-hov self-requested a review August 17, 2023 18:48
@piux2
Copy link
Contributor

piux2 commented Aug 18, 2023

I don’t see we need to relate the prevRealm() with IsOriginCall() on the Frame.

On chain VM runtim, the calls are initiated from user EOA account to the realm function calls directly. No main function involved.

1st machine.Frame[0] the call to a realm pkg method Foo() through VM Keeper to machine.RunFunc()
2nd machine.Frame[1] in function Foo, std.IsOriginCall() is true It has to be on the 2nd frame to determine current frame is the call from a user (EOA)

For filetest, function test call starting from main() in a main package. The main package address is used to fake a user caller (EOA)

1st machine.Fram[0] the call to gno main package main() through file test to machine.RunMain()
2nd machine.Fram[1] call a realm method or a function in main package from main() to Foo()
3nd machine.Fram[2] in Foo() std.IsOriginCall() is true, it has to be on the 3nd frame to determine current frame is the call from a main package, fake user (EOA)

That is why the injected package method need to overwrite on IsOrginCall() for file tests.

These are different usage cases. They are not tight to realm, no need to unify them on the frame.

Here are some other suggestions related to this. I will create a separate RFC for these.

  • m.LastFrame().LastRealm is prevRealm from the call stack , m.Realm is current active realm. we don’t need to loop through frames in injected “PrevRealm” and “ActiveRealm”

  • We should return realm package path instead of address, to differentiate Contract identity from a user EOA

  • To avoid users sending tokens to Contract address accidentally, we should use address for EOA and package path for contracts as identifier to users and gno devs. On the other platform, the fallback function was originally created just for solving this problem due to the limitation of VM, which opens many other security issues including the infamous reentry()

@tbruyelle
Copy link
Contributor Author

Hey @tbruyelle,

Can you check why the CI is acting freaky with this PR?

This is because of #1036, we probably need to fix that issue before being able to merge this PR.

@tbruyelle
Copy link
Contributor Author

@piux2 I understand your point, the goal is to base all functions (OriginCall and PrevRealm) to a single algorithm, and also to avoid test overriding. Test overriding is really annoying, it's not just about changing the number of frames from 2 to 3, because this is true only for "_filetest.gno". For "_test.gno", the number of frames is 7. Since we need to make that distinction, there's also an other exception that need to be handled (init in "*_filetest.no"). If look at the test overrinding for IsOriginCall, I think it's nice to remove it thanks to this PR :

	isOriginCall := func(m *gno.Machine) bool {
		tname := m.Frames[0].Func.Name
		switch tname {
		case "main": // test is a _filetest
			return len(m.Frames) == 3
		case "runtest": // test is a _test
			return len(m.Frames) == 7
		}
		// support init() in _filetest
		// XXX do we need to distinguish from 'runtest'/_test?
		// XXX pretty hacky even if not.
		if strings.HasPrefix(string(tname), "init.") {
			return len(m.Frames) == 3
		}
		panic("unable to determine if test is a _test or a _filetest")
	}

These are different usage cases. They are not tight to realm, no need to unify them on the frame.

The PrevRealm algorithm returns the user if there's no frames or no realm inside the frames, so even if one day a user can call a package directly (which is not possible if I'm not wrong), IsOriginCall will return true. We can move that algorithm into a private function with a different name maybe, but I think there's a link between *OriginCall and PrevRealm.

  • m.LastFrame().LastRealm is prevRealm from the call stack , m.Realm is current active realm. we don’t need to loop through frames in injected “PrevRealm” and “ActiveRealm”

m.Realm is the active realm, this is not what PrevRealm returns but maybe I misunderstood your point.

  • We should return realm package path instead of address, to differentiate Contract identity from a user EOA

PrevRealm returns a Realm struct that contains both address and package path (which is empty if the Realm represents the EOA). I don't really like the API because it mixes Realm and EOA, if you have a better idea that would be nice.

  • To avoid users sending tokens to Contract address accidentally, we should use address for EOA and package path for contracts as identifier to users and gno devs. On the other platform, the fallback function was originally created just for solving this problem due to the limitation of VM, which opens many other security issues including the infamous reentry()

Interesting point, I'm not aware of this reentry issue.

Comment on lines 25 to 28
// isOriginCall returns true if the std.OrigCaller == std.PrevRealm
func isOriginCall(m *gno.Machine) bool {
return prevRealm(m).addr == m.Context.(ExecContext).OrigCaller
}
Copy link
Member

Choose a reason for hiding this comment

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

Could we not, at this point, implement IsOriginCall() as Gno code, simply return std.PrevRealm().Addr() == std.GetOrigCaller()?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah good idea, and I realize it can be even simpler like return std.PrevRealm().IsUser()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@thehowl so I tried but it seems I fall into the same issue than #875, it's not possible to call a native function from gno files located in stdlibs/std/. If true let's delay your idea to when Improved native bindings is merged.

Copy link
Contributor

Choose a reason for hiding this comment

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

@tbruyelle FYI, #875 doesn't have this kind of issue any more. But it looks like isOriginCall has more complex conditions which can't be solved at this point.

@moul moul added this to the 🌟 main.gno.land (wanted) milestone Sep 8, 2023
@tbruyelle
Copy link
Contributor Author

@moul I had to fix a couple of other tests, but this was for the same reason as /r/boards : std.AssertOriginCall is now more reliable in the test context than before.

So for instance:

  • you can't use the // PKGPATH: gnoland/r/... instruction in your test if your code invokes std.AssertOriginCall, unless you want to trigger a panic
  • in gnovm/tests/files setup, it's not possible to have more than one realm in the frame list, so I had to remove tests/files/std9.gno, which was relying on a incorrect contract.

Copy link
Member

Choose a reason for hiding this comment

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

Is this diff expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it also surprised me, then I realized this is because the package name has changed, and the test is no longer a realm. So I assumed this is why the // Realm: data is so different than before.

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 check what's inside if it's not a realm anymore? shouldn't be shorter?

Maybe we should disable the //Realm directive for this test.

Copy link
Contributor Author

@tbruyelle tbruyelle Sep 11, 2023

Choose a reason for hiding this comment

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

Wow sry I didn't realized the change added so many lines! We definitively need to fix that, thanks for raising!

So I dived a little bit more to understand, so the key differences come from the number of operations added in the store, and there's 4 kinds of operation (switchrealm, create, update and delete). Here is the report :

z_4_filetest.gno as a realm (before this PR)

  • total number of ops: 30
  • switchrealm 15
    [switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/test"]]
  • create 10
  • update 5
  • delete 0

z_4_filetest.gno not a realm (in this PR)

  • total number of ops: 13691
  • switchrealm 49
    [switchrealm["strconv"] switchrealm["errors"] switchrealm["std"] switchrealm["internal/os"] switchrealm["time"] switchrealm["gno.land/p/demo/avl"] switchrealm["unicode"] switchrealm["sort"] switchrealm["unicode/utf8"] switchrealm["io"] switchrealm["internal/bytealg"] switchrealm["strings"] switchrealm["regexp/syntax"] switchrealm["bytes"] switchrealm["regexp"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/boards"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/users"] switchrealm["gno.land/r/demo/boards"]]
  • create 13615
  • update 27
  • delete 0

So as we can notice there's many more switchrealm in the second version, which also implies a lot more creates operations. Seems like in this case the store records also non-realm packages like strconv, errors, std, etc, whereas the first case limits the record to realm packages only.

I tried to understand what's the code involved internally, but that's a difficult task. I can dive further, or else we can just remove the Realm: instruction from this test and move on.

Copy link
Contributor Author

@tbruyelle tbruyelle Sep 11, 2023

Choose a reason for hiding this comment

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

Here is more detailed report, where the number of create/update/delete are associated with their realm. The 3 numbers behind the switchrealm are respectively the number of create, update and delete.

z_4_filetest.gno as a realm (before this PR)

0 {switchrealm["gno.land/r/demo/users"] 0 0 0}
1 {switchrealm["gno.land/r/demo/users"] 0 0 0}
2 {switchrealm["gno.land/r/demo/boards"] 10 5 0}
3 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
4 {switchrealm["gno.land/r/demo/users"] 0 0 0}
5 {switchrealm["gno.land/r/demo/users"] 0 0 0}
6 {switchrealm["gno.land/r/demo/users"] 0 0 0}
7 {switchrealm["gno.land/r/demo/users"] 0 0 0}
8 {switchrealm["gno.land/r/demo/users"] 0 0 0}
9 {switchrealm["gno.land/r/demo/users"] 0 0 0}
10 {switchrealm["gno.land/r/demo/users"] 0 0 0}
11 {switchrealm["gno.land/r/demo/users"] 0 0 0}
12 {switchrealm["gno.land/r/demo/users"] 0 0 0}
13 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
14 {switchrealm["gno.land/r/test"] 0 0 0}

z_4_filetest.gno not a realm (in this PR)

0 {switchrealm["strconv"] 3 0 0}
1 {switchrealm["errors"] 3 0 0}
2 {switchrealm["std"] 8 0 0}
3 {switchrealm["internal/os"] 3 0 0}
4 {switchrealm["time"] 18 0 0}
5 {switchrealm["gno.land/p/demo/avl"] 4 0 0}
6 {switchrealm["unicode"] 13261 0 0}                    <---- culprit
7 {switchrealm["sort"] 4 0 0}
8 {switchrealm["unicode/utf8"] 21 0 0}
9 {switchrealm["io"] 13 0 0}
10 {switchrealm["internal/bytealg"] 8 0 0}
11 {switchrealm["strings"] 8 0 0}
12 {switchrealm["regexp/syntax"] 72 0 0}
13 {switchrealm["bytes"] 9 0 0}
14 {switchrealm["regexp"] 13 0 0}
15 {switchrealm["gno.land/r/demo/users"] 0 0 0}
16 {switchrealm["gno.land/r/demo/users"] 0 0 0}
17 {switchrealm["gno.land/r/demo/users"] 46 0 0}
18 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
19 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
20 {switchrealm["gno.land/r/demo/boards"] 79 0 0}
21 {switchrealm["gno.land/r/demo/users"] 3 3 0}
22 {switchrealm["gno.land/r/demo/users"] 0 0 0}
23 {switchrealm["gno.land/r/demo/users"] 0 0 0}
24 {switchrealm["gno.land/r/demo/boards"] 6 3 0}
25 {switchrealm["gno.land/r/demo/users"] 0 0 0}
26 {switchrealm["gno.land/r/demo/users"] 0 0 0}
27 {switchrealm["gno.land/r/demo/boards"] 7 2 0}
28 {switchrealm["gno.land/r/demo/users"] 0 0 0}
29 {switchrealm["gno.land/r/demo/users"] 0 0 0}
30 {switchrealm["gno.land/r/demo/boards"] 8 3 0}
31 {switchrealm["gno.land/r/demo/users"] 0 0 0}
32 {switchrealm["gno.land/r/demo/users"] 0 0 0}
33 {switchrealm["gno.land/r/demo/boards"] 8 7 0}
34 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
35 {switchrealm["gno.land/r/demo/users"] 0 0 0}
36 {switchrealm["gno.land/r/demo/users"] 0 0 0}
37 {switchrealm["gno.land/r/demo/boards"] 10 9 0}
38 {switchrealm["gno.land/r/demo/boards"] 0 0 0}
39 {switchrealm["gno.land/r/demo/users"] 0 0 0}
40 {switchrealm["gno.land/r/demo/users"] 0 0 0}
41 {switchrealm["gno.land/r/demo/users"] 0 0 0}
42 {switchrealm["gno.land/r/demo/users"] 0 0 0}
43 {switchrealm["gno.land/r/demo/users"] 0 0 0}
44 {switchrealm["gno.land/r/demo/users"] 0 0 0}
45 {switchrealm["gno.land/r/demo/users"] 0 0 0}
46 {switchrealm["gno.land/r/demo/users"] 0 0 0}
47 {switchrealm["gno.land/r/demo/users"] 0 0 0}
48 {switchrealm["gno.land/r/demo/boards"] 0 0 0}

so we can see that this is the unicode package that implies so many create, logically because the code contains many variable declarations.

@thehowl
Copy link
Member

thehowl commented Sep 12, 2023

Just found out this PR will fix another issue in the current behaviour of IsOriginCall/AssertOriginCall. Because the old behaviour was to rely on len(m.Frames), the first block of code will work, but the second one will fail: (NewGame() is a function that calls AssertOriginCall)

func TestNewGame(t *testing.T) {
	g := NewGame(std.DerivePkgAddr("xx").String())
	println(g)
}
func TestNewGame(t *testing.T) {
	for range [...]string{""} {
		g := NewGame(std.DerivePkgAddr("xx").String())
		println(g)
	}
}

The reason seems to be that when we enter a for loop we add a new frame (for handling break/continue)

@tbruyelle
Copy link
Contributor Author

@thehowl cool, and yes that demonstrates that using the number of frames is really too weak!

@moul
Copy link
Member

moul commented Sep 13, 2023

This PR is essential for the upcoming gnochess event.

@tbruyelle, could you please focus on reducing the substantial changes, which currently amount to 600k lines?

@piux2, we would appreciate it if you could conduct another round of reviews. Your previous concerns extended beyond the PR itself and should be addressed separately, outside the scope of this PR.

@tbruyelle tbruyelle force-pushed the refac/assertOriginCaller branch from 7fbfe14 to 27d1379 Compare September 13, 2023 18:49
@tbruyelle tbruyelle requested review from piux2 and a team as code owners February 5, 2024 17:32
TODO see if that passes the tests
@leohhhn
Copy link
Contributor

leohhhn commented Feb 5, 2024

Hey @tbruyelle, are you aware of this discussion?

EDIT: Ah, I see that you've already discussed with @piux2.

My two cents on this is that we shouldn't push developers to require OriginCalls to their code, since it simply limits functionality. I would keep the option in, but mainly promote PrevRealm as the "correct" usage.

Ethereum has the same concept (msg.sender vs tx.origin), and tx.origin was only kept in because of legacy code.

The real question is this (@piux2): why would you want to limit your realm/function to be called only directly by an EOA (wallet)? What is a good reason for not allowing a "middleman" realm/package? IMHO, it only blocks useful functionality like account abstraction, and is generally not very well accepted by the community (ie you're not fully permissionless/you are in a way "censoring" usage). I don't think security should be reason why we should block non-origin calls.

@tbruyelle
Copy link
Contributor Author

tbruyelle commented Feb 6, 2024

The real question is this (@piux2): why would you want to limit your realm/function to be called only directly by an EOA (wallet)?

Nuclear bomb button dapp was mentionned as a usecase for AssertOriginCall with Jae during discussions :)

Other than that, I agree with you, I would prefer to rely only on PrevRealm!

@tbruyelle
Copy link
Contributor Author

tbruyelle commented Feb 6, 2024

@piux2 Do you think it's acceptable for AssertOriginCall to ignore packages whose name doesn't start with gno.land?

This way it will ignore all stdlibs packages, which will make it work in tests without having to override it in gnovm/tests/stdlibs (because of the testing package that appears in the call stack for tests). This also avoids having to manually ignore the main package introduced by MsgCall.

But it will still panic if there's an additional gno.land/p package in the call stack, so cases 7 & 9 will panic.

@piux2
Copy link
Contributor

piux2 commented Feb 15, 2024

@piux2 Do you think it's acceptable for AssertOriginCall to ignore packages whose name doesn't start with gno.land?

This way it will ignore all stdlibs packages, which will make it work in tests without having to override it in gnovm/tests/stdlibs (because of the testing package that appears in the call stack for tests). This also avoids having to manually ignore the main package introduced by MsgCall.

But it will still panic if there's an additional gno.land/p package in the call stack, so cases 7 & 9 will panic.

Here is the lastest we can discuss.

a. MsgRun behaves the same as MsgCall; the main contract in MsgRun is trusted, just as in MsgCall.

b. p/ is treated the same as r/. (However, because p/ can no longer import r/, the p/ package will not act as a middleman. Does this allow rules 7 and 9 to pass directly?)

c. 1 results in a PANIC because AssertOrigin kicks in when AssertOrigin protects the entry point of the entire realm when it is in the call path

Num Msg Type Call from Entry Point Result
1 MsgCall wallet direct myrealm.A() PANIC
2     myrealm.B() pass
3     myrealm.C() pass
4   through /r/foo myrealm.A() PANIC
5     myrealm.B() pass
6     myrealm.C() PANIC
7   through /p/demo/bar myrealm.A() PANIC
8     myrealm.B() pass
9     myrealm.C() PANIC
10 MsgRun wallet direct myrealm.A() Panic
11     myrealm.B() pass
12     myrealm.C() pass
13   through /r/foo myrealm.A() PANIC
14     myrealm.B() pass
15     myrealm.C() PANIC
16   through /p/demo/bar myrealm.A() PANIC
17     myrealm.B() pass
18     myrealm.C() PANIC

@thehowl
Copy link
Member

thehowl commented Feb 15, 2024

formatted A/B/C functions being talked about:

// realm pkg: gno.lang/r/myrealm

package myrealm

import (
	"std"
)

func A() string {
	C()
	return "return from A()"
}

func B() string {
	if false {
		C()
	}
	return "return from B()"
}

func C() string {
	std.AssertOriginCall() //   same as AssertIsUser()
	return "return from C()"
}

@kristovatlas
Copy link
Contributor

Following.

@thehowl
Copy link
Member

thehowl commented Feb 15, 2024

tl;dr

We have a decision on the design:

  • std.AssertOriginCall() should be a method to explicitly ensure that the
    given function call is being made directly by MsgCall. This is a UX security
    decision.
  • std.GetOrigCaller() should be REMOVED, as we cannot think of any use cases
    where its usage would be correct.
  • std.PrevRealm() should work the same for calling a realm through MsgRun OR
    MsgCall.
    • ie. MsgCall myrlm.A, or MsgRun func main() { myrlm.A() }, will both
      yield a std.PrevRealm() where IsUser() == true, Address() == user
      address, PkgPath() == "" (this last one subject to future discussion).

std.PrevRealm() is THE security mechanism for gno.land. It allows for
composability, re-using functions within the same realm, ACLs, while
intentionally making it possible for a end-user to execute arbitrary code
composing many functions being run on its behalf.

std.AssertOriginCall() is the security mechanism that enforces MsgCall to be
used. A realm author should use this with care and consider whether it truly
makes sense to use it. The decision should boil down to what the user is shown
when signing the transaction in their software or hardware wallet:

Is this transaction so important that a non-technical user should be able to
clearly see that it goes directly to the intended realm, with no middleman
that can attempt to modify it, with the specific given parameters?

the table

From the table written by @piux2, this entails only one change (line 12):

Num Msg Type Call from Entry Point Result
1 MsgCall wallet direct myrealm.A() PANIC
2     myrealm.B() pass
3     myrealm.C() pass
4   through /r/foo myrealm.A() PANIC
5     myrealm.B() pass
6     myrealm.C() PANIC
7   through /p/demo/bar myrealm.A() PANIC
8     myrealm.B() pass
9     myrealm.C() PANIC
10 MsgRun wallet direct myrealm.A() PANIC
11     myrealm.B() pass
12     myrealm.C() PANIC
13   through /r/foo myrealm.A() PANIC
14     myrealm.B() pass
15     myrealm.C() PANIC
16   through /p/demo/bar myrealm.A() PANIC
17     myrealm.B() pass
18     myrealm.C() PANIC

A summary of the discussion

There are two main use cases brought up for AssertOriginCall:

  • Fairness in the transaction being called: all users calling the function at
    the same time have an equal chance of being picked.
  • UX Security: when signing a transaction on their wallet, the user knows what
    they're actually doing.

The first one is not actually "solved" by AssertOriginCall, as it is possible
for MsgCalls to be batched together in the same transaction. (See below for a
proposal on this front).

There is one notable concern in providing a mechanism like AssertOriginCall:
using it to guard your exported realm function severely harms the ecosystem's
composability. Other realms cannot call your realm and perform actions.

AssertOriginCall, however, explicitly does not solve the issue of
impersonation properly. It seems to solve it, as with AssertOriginCall +
GetOrigCaller you're guaranteed that the result of GetOrigCaller is the caller
and signer of the transaction. However, this comes at the expense of
composability and ignores the fact that the proper way to avoid "MITM" attacks
(ie. a middle realm calling a final realm pretending to be the EOA) is through
PrevRealm(), a function that treats the realms as their own entitites and
not as an inherent authorization of the user.

Therefore, the reason why you should use AssertOriginCall is for safety in the
end-user experience
. What that means is that if a transaction if destructive,
or moves money, the user shouldn't have to fear for their lives every time they
run MsgRun.

Warning! You are about to sign the following:

Message type: /vm.m_run
package main

import (
	"time"

	"gno.land/r/oracles/weather"
	"gno.land/r/demo/airdropper"
	"gno.land/r/demo/chess"
)

func main() {
	// Get the current weather in Rome
	forecast := weather.Forecast(weather.Locate("Rome"), time.Now().Add(time.Hour*6))
	switch forecast.Type {
	case weather.Sunny:
		// Play a game of chess!
		chess.JoinLobby()
	case weather.Rainy:
		// Steal the money!
		airdropper.ClaimAirdrop()
		std.GetBanker(std.BankerTypeRealmSend).
			SendCoins(std.PrevRealm().Addr(), "g1eviluser", Coins{Coin{"ugnot", "12341234"}})
	}
}
Warning! You are about to sign the following:

Message type: /vm.m_call
Package path: gno.land/r/demo/airdropper
Function: ClaimAirdrop

It makes sense for claiming an airdrop to be an explicit action that
requires an explicit agreement on the user, instead of depending on some
arbitrary code that decides to do it based on the weather in Rome this
afternoon, and then is capable of stealing it because I'm not a programmer and
didn't appropriately review the code.

On the other side, the input of m_call is much more visible and understandable
even as a non-programmer; and that's where its strength resides. Obviously we
will need further hardening of security mechanisms on the wallet side; but its
benefits seem to outweigh the cost of risking harming the ecosystem's
composability.

To the extent of enforcing realm authors to use safe mechanisms, we're removing
GetOrigCaller to disallow access control mechanisms which use it. To the extent
of superpowering MsgRun in a composable ecosystem, we will be making it as a
PrevRealm entity effectively the same as what you get when performing MsgCall;
separating the security mechanism for the UX side explained above with the
specific function AssertOriginCall.

A note on closures. In the "scribbles" below I make an example of ClaimAirdrop
being called through a closure. This fails in principle (because you cannot use
MsgRun + AssertOriginCall); if it were not there, however, PrevRealm() would
have to be the realm of MsgRun (the "user realm"), as the execution goes back to
a function (closure) defined within that realm.

Scribbles

Here are the notes I was writing down during the meeting:

package MsgRun

func main() {
	attacker.CallClosure(func() { c.ClaimAirdrop() })
}

package attacker

func CallClosure(f func()) { f() }

package c // realm

func ClaimAirdrop() {
	std.AssertOriginCall()   // UX choice (can only use MsgCall)
	REMOVE std.GetOrigCaller

	std.PrevRealm() // => avoids impersonation, enforced security mechanism for realms
	std.PrevRealm().IsUser()  // MsgRun -> true;           MsgCall -> true
	std.PrevRealm().Address() // MsgRun -> user addr;      MsgCall -> user addr
	std.PrevRealm().PkgPath() // MsgRun -> "";             MsgCall -> ""

	// TBD section, for future consideration:
	// PkgPath(): "" -> gno.land/u/g13312asdkjask?
	// std.IsSingleMessageTransaction() // -> true enforcer of "mempool-fairness"
}

For future consideration

A couple of discussion points left for the future.

Fair MsgCalls

One feature that would be good to have is a "fairness" mechanism for calling a
smart contract.

Let's say we have a BuyConcertTicket() realm function, which wants to create
a fair system that is not subject to hoarding. It still works first-come-first-serve;
this function disallows buying multiple tickets from a single address, but it
is still possible for a user to create a transaction with multiple MsgCalls
from different callers (signing the transaction).1

To avoid this, there could be a system to ensure that the transaction being
performed is a single-message transaction. This would ensure that the method for
"picking" the transaction relies on the mempool and is fair with all other
competing transactions; a user cannot try to take advantage with multiple,
batched calls, or other mechanism,s.

A PkgPath() for users

What if the PkgPath() returned when PrevRealm() is a user, was something
like gno.land/u/g1address?

This would:

  • Create a clear "pkgpath" where the users "run"
  • avoid reserving realm paths in gno.land/r/xxx for MsgRun
  • explicitly mark visually in the PkgPath that this is a user

This might be useful; or an unnecessary complication: finding this out is left
as an exercise for the reader to make an opinion in time for when this will come
up again.

Footnotes

  1. may not be entirely true, but we will most definitely have multisig at
    some point so some kind of problem like this will arise.

Turn back AssertOriginCall to original implementation.
Unfortunately, this still requires to override it for tests (wip).
@tbruyelle
Copy link
Contributor Author

tbruyelle commented Feb 17, 2024

Many thanks @thehowl for your last comment which summarizes everything. I think we should close this PR, the title is not accurate (and changing it will make the discussions obsoletes) and many changes in the code aren't relevant any more.

Regarding the immediate work that needs to be done given your comment, I suggest to split into different PRs/issues:

tbruyelle added a commit to tbruyelle/gno that referenced this pull request Feb 18, 2024
Fix gnolang#1663

`AssertOriginCall` used to panic on `MsgRun` because it involves more
than 2 frames. But we want to reinforce that property, with an
additional check.

Added an improved version of the unit and txtar tests from gnolang#1048. In
particular, the txtar adds 2 more cases :

19) MsgCall invokes std.AssertOriginCall directly: pass
20) MsgRun invokes std.AssertOriginCall directly: PANIC

Note that 19) involves a change in the AssertOriginCall algorithm,
because in that situation there's only a single frame. Even if there's
no reason to call `std.AssertOriginCall()` directly from the command
line, I think it's logic to make it pass, because that's a origin call.
tbruyelle added a commit to tbruyelle/gno that referenced this pull request Feb 18, 2024
Fix gnolang#1663

`AssertOriginCall` used to panic on `MsgRun` because it involves more
than 2 frames. But we want to reinforce that property, with an
additional check.

Added an improved version of the unit and txtar tests from gnolang#1048. In
particular, the txtar adds 2 more cases :

19) MsgCall invokes std.AssertOriginCall directly: pass
20) MsgRun invokes std.AssertOriginCall directly: PANIC

Note that 19) involves a change in the AssertOriginCall algorithm,
because in that situation there's only a single frame. Even if there's
no reason to call `std.AssertOriginCall()` directly from the command
line, I think it's logic to make it pass, because that's a origin call.
tbruyelle added a commit to tbruyelle/gno that referenced this pull request Apr 22, 2024
Fix gnolang#1663

`AssertOriginCall` used to panic on `MsgRun` because it involves more
than 2 frames. But we want to reinforce that property, with an
additional check.

Added an improved version of the unit and txtar tests from gnolang#1048. In
particular, the txtar adds 2 more cases :

19) MsgCall invokes std.AssertOriginCall directly: pass
20) MsgRun invokes std.AssertOriginCall directly: PANIC

Note that 19) involves a change in the AssertOriginCall algorithm,
because in that situation there's only a single frame. Even if there's
no reason to call `std.AssertOriginCall()` directly from the command
line, I think it's logic to make it pass, because that's a origin call.
@thehowl thehowl self-assigned this May 14, 2024
thehowl added a commit that referenced this pull request May 27, 2024
…#1665)

Fix #1663

`AssertOriginCall` used to panic on `MsgRun` because it involves more
than 2 frames. But we want to reinforce that property, with an
additional check.

Added an improved version of the unit and txtar tests from #1048. In
particular, the txtar adds 2 more cases :

19) MsgCall invokes std.AssertOriginCall directly: pass
20) MsgRun invokes std.AssertOriginCall directly: PANIC

Note that 19) involves a change in the AssertOriginCall algorithm,
because in that situation there's only a single frame. Even if there's
no reason to call `std.AssertOriginCall()` directly from the command
line, I think it's logic to make it pass, because that's a origin call.

To run the txtar test:
```
$ go test ./gno.land/cmd/gnoland/ -v -run TestTestdata/assert
```

<details><summary>Contributors' checklist...</summary>

- [x] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [x] Updated the official documentation or not needed
- [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [x] Added references to related issues and PRs
- [x] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
</details>

---------

Co-authored-by: Leon Hudak <[email protected]>
Co-authored-by: Morgan Bazalgette <[email protected]>
omarsy pushed a commit to TERITORI/gno that referenced this pull request Jun 3, 2024
…gnolang#1665)

Fix gnolang#1663

`AssertOriginCall` used to panic on `MsgRun` because it involves more
than 2 frames. But we want to reinforce that property, with an
additional check.

Added an improved version of the unit and txtar tests from gnolang#1048. In
particular, the txtar adds 2 more cases :

19) MsgCall invokes std.AssertOriginCall directly: pass
20) MsgRun invokes std.AssertOriginCall directly: PANIC

Note that 19) involves a change in the AssertOriginCall algorithm,
because in that situation there's only a single frame. Even if there's
no reason to call `std.AssertOriginCall()` directly from the command
line, I think it's logic to make it pass, because that's a origin call.

To run the txtar test:
```
$ go test ./gno.land/cmd/gnoland/ -v -run TestTestdata/assert
```

<details><summary>Contributors' checklist...</summary>

- [x] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [x] Updated the official documentation or not needed
- [x] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [x] Added references to related issues and PRs
- [x] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
</details>

---------

Co-authored-by: Leon Hudak <[email protected]>
Co-authored-by: Morgan Bazalgette <[email protected]>
@thehowl
Copy link
Member

thehowl commented Oct 28, 2024

Closing in favour of #1577 and #2906 (the remaining issues pointed out by @tbruyelle are now solved, and IIRC we have txtars which match the "table" pointed out in my comment).

@thehowl thehowl closed this Oct 28, 2024
@tbruyelle tbruyelle deleted the refac/assertOriginCaller branch October 29, 2024 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📦 ⛰️ gno.land Issues or PRs gno.land package related 📦 🤖 gnovm Issues or PRs gnovm related 🧾 package/realm Tag used for new Realms or Packages.
Projects
Status: Done
Status: 🌟 Wanted for Launch
Status: No status
Development

Successfully merging this pull request may close these issues.

9 participants