wharfkit / antelope

Core types, client interfaces, and other tools for working with Antelope-based blockchains.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Authority key sorting

jnordberg opened this issue · comments

To update an accounts permissions nodeos enforces that the keys are sorted, the sorting logic comes from the fc library and has to be re-created for every type involved. Fortunately due to a quirk of how it is implemented for 99% of cases we can get away with sorting the string representations of the types in question.

For now the Authority type will use the string sorting hack. But long-term we should add a compare requirement to all core types and replicate fc's sorting behavior for all of them.

Attaching chat log from Telegram discussing this for posterity

Aaron Cox (jesta) — Greymass, [Jan 27, 2021 11:44:43 (Jan 27, 2021 11:45:13)]:
https://github.com/EOSIO/eos/blob/0d87dff8bee56179aa01472dd00a089b2aa7b9fa/libraries/chain/include/eosio/chain/authority.hpp#L273

By what method are keys sorted here? We are digging through the source, trying to replicate the sorting in the frontend/wallet layer to ensure transactions we generate with multiple keys in an authority are ordered properly - but haven't found the answer yet.

This was a fun one to track down as to why some multikey auth updates were failing 😂

Syed | Bloks.io, [Jan 27, 2021 11:46:59 (Jan 27, 2021 11:47:04)]:
just localeCompare the key or actor in JS

Johan Nordberg, [Jan 27, 2021 11:52:35]:
Are you saying we should convert the keys to the string (PUB_..) representation and sort them alphabetically?

Syed | Bloks.io, [Jan 27, 2021 11:53:39 (Jan 27, 2021 11:53:44)]:
alphanumerically using localeCompare

required_auth.keys     = required_auth.keys.sort((a: { key: any; }, b: { key: any; }) => a.key.localeCompare(b.key))
required_auth.accounts = required_auth.accounts.sort((a: { permission: { actor: any; }; }, b: { permission: { actor: any; }; }) => a.permission.actor.localeCompare(b.permission.actor))
required_auth.waits    = required_auth.waits.sort((a: { wait_sec: any; }, b: { wait_sec: any; }) => a.wait_sec.localeCompare(b.wait_sec))

Johan Nordberg, [Jan 27, 2021 11:54:50]:
Does that work with R1 keys as well?

Syed | Bloks.io, [Jan 27, 2021 11:55:05]:
probably, try it out and let me know

Aarin Hagerty, [Jan 27, 2021 12:05:38 (Jan 27, 2021 12:11:02)]:
That is an interesting coincidence if that actually works. Especially given how arbitrary it is that the variant ordering of the three different types of keys just happens to be consistent with the lexicographic ordering of the ASCII characters of the key type prefixes.

Johan Nordberg, [Jan 27, 2021 12:12:08]:
It seems to be working

Looks like it would work with WA_ keys as well since that's the third variant (and I'm understanding how fc sorts variants correctly)

Someone should put a note in that code saying that the next key prefix has to start with X or they will break everyone 😂

Aarin Hagerty, [Jan 27, 2021 12:16:12 (Jan 27, 2021 12:17:59)]:
Yeah. That’s the coincidence part. First, EOSIO supported K1 and R1 keys and the prefixes were PUB_K1_ and PUB_R1_ respectively. Those prefixes happened to be sorted in the same order as the key types are sorted in the variant. Then later WebAuthn keys were added which had to necessarily be the third entry in the variant for backwards compatibility reasons. Well it really worked out that the obvious prefix for it (PUB_WA_) happened to be sorted last.

Syed | Bloks.io, [Jan 27, 2021 12:19:19 (Jan 27, 2021 12:19:49)]:
Well I found it by tinkering about god knows when 😄 the K, R, W is pure chance for sure

Johan Nordberg, [Jan 27, 2021 12:23:31]:
Awesome, thanks for the help

Aarin Hagerty, [Jan 27, 2021 12:25:16 (Jan 27, 2021 12:38:25)]:
Although I think it still may break down for WebAuthn keys already if the rpId is sufficiently different for otherwise identical keys in the same authority.

For example, I would try with an authority containing two WebAuthn keys with different rpId. The one that has an rpId which is sorted earlier lexicographically should have a longer rpId than the other one. So more concretely, two WebAuthn keys that are exactly identical except for the rpId. One could have an rpId of longer.com and the other can have an rpId of short.com. I believe your sorting method will put them in an order that might be rejected by nodeos.

Johan Nordberg, [Jan 27, 2021 12:52:02]:
Too bad, do you happen to know where in FC one would look to see exactly how they would be sorted? I've gotten as far as the variants numbered 1,2,3 etc and sorted on that and assuming that the key data is just compared byte by byte since it works with the base58 representation

Aarin Hagerty, [Jan 27, 2021 13:32:47 (Jan 27, 2021 13:36:15)]:
EOSIO/eos
An open source smart contract platform . Contribute to EOSIO/eos development by creating an account on GitHub.

Aarin Hagerty, [Jan 27, 2021 13:32:47 (Jan 27, 2021 13:36:15)]:
It’s messy. The representation of the public key people normally see and deal with is the string representation that includes the prefix and base58 encoding of data which consists of the binary serialization of the relevant parts of the public key (without the discriminator determining the type of the public key) appended with checksum data committing to that binary serialization. When nodeos does its validations it is comparing the public key data structures that have been deserialized from the full binary serialization. At the outer layer (the one triggered by this line https://github.com/EOSIO/eos/blob/0d87dff8bee56179aa01472dd00a089b2aa7b9fa/libraries/chain/include/eosio/chain/authority.hpp#L273), it triggers this code: https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/src/crypto/public_key.cpp#L100-L103.  That then triggers this code (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/common.hpp#L168-L170) which sorts all K1 keys before all R1 keys and all R1 keys before WebAuthn keys. To compare two keys of the same type, it delegates that to this line (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/common.hpp#L162) which should then call the appropriate template specialization of the apply function depending on the underlying types used for the different types of keys. Actually in all three cases it just gets to this line (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/common.hpp#L148) but with type T instantiated to one of three different types. In the case of K1 and R1 keys, the serialize call just returns a fc::array<char,33> which was the only data ultimately stored within the _storage field here (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/public_key.hpp#L45) for K1 and R1 keys; this was set during the creation of the public_key object (perhaps from deserialization) to the 33-byte serialized compact representation of an elliptic curve point. There is a less than operator defined on the fc::array types which just compares them lexicographically. In the case of WebAuthn keys, the serialize call just returns the fc::crypto::webauthn::public_key type (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/elliptic_webauthn.hpp#L21) which has a less than operator defined on it: https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/elliptic_webauthn.hpp#L44. The public_key_data field is the same as the 33-byte data from R1 keys. The user_verification_type field is a single byte representing the user presence flag of the WebAuthn key. And the rpid key is the rpId of the WebAuthn key (https://www.w3.org/TR/webauthn-2/#relying-party-identifier). All of these fields are extracted from the raw binary during deserialization (https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/elliptic_webauthn.hpp#L55-L64). Note that when I am talking about the raw binary form I am not talking about Base58 encoding. The raw binary form is at the beginning of the binary string resulting from parsing the Base58 encoding after the prefix within the string representation of the public key. That resulting binary string also has a 4 byte checksum at the end which commits to the raw binary form as well as the prefix type. You can find the code for that here: https://github.com/EOSIO/fc/blob/a3694752d2dd2021185955eafc1a504efaabfa79/include/fc/crypto/common.hpp#L10-L86.

Johan Nordberg, [Jan 27, 2021 13:46:49]:
Wow thanks! So If I understand this correctly I should be able to (after base58check decoding the key data): compare(data[0:33]) && compare(data[33:34]) && compare(data[34:])

Aarin Hagerty, [Jan 27, 2021 13:48:27]:
I don’t think the && is the correct way to express the tie breaking but you have the general idea right with the exception that comparing the last field is a bit more complicated.

The serialization of an std::string to binary form should first pack the length as a variable-length integer and then the bytes of the string.

But the strings are compared lexicographically. Hence longer.com should come before short.com but in the serialization the first byte of longer.com would be 10 (its length) while the first byte of short.com would be 9 (again its length). That means just comparing the raw bytes would consider short.com to come before longer.com.

Johan Nordberg, [Jan 27, 2021 13:52:35]:
Ok got it, so that's why you can't compare the base58 encoded string version of the key

Aarin Hagerty, [Jan 27, 2021 13:53:03]:
Right. It was so very close, but I do believe that example I gave above would break it (haven’t verified though).

Google takes me here. Though it may be out of topic, I'd like to share my experience. In C++ Smart Contracts if you sort public_key vector directly, you will not get what you want.

The following example works:

bool sort_public_key (public_key a, public_key b) {
   const auto& a1 = std::get<0>(a);
   const auto& b1 = std::get<0>(b);
   return memcmp(a1.data(), b1.data(), 33) < 0;
}
   vector<public_key> keys;
   sort(keys.begin(), keys.end(), sort_public_key);

In TS, base58 representation of public key sorting works too, don't worry about it.

For lexographic comparison of K1 and R1 keys in JS/TS, I have been using localeCompare so far, but it breaks with WA keys, the following example will error in updateauth:

"EOS5fMyAUopVJv88Wb4szbLH2ds65jiNCjv1XWRRyvrfR6oEBdZXk",
"PUB_WA_323xpHU17pKZ6VsygcdXxq7cgosxSyRU5KGevyjUNtw4m9Y63EzPs3SEVpsf7rjeVLa7",
"PUB_WA_4B6ZbE2hxTvcrndvS8758EjGqRMQqVoV4vBTGgvqi27HNw8xyQz6viKrGLNLcaiRmhmbuyu584Hf",
"PUB_WA_4vD5irsd1GdmTEhea5G8QideW3NqU8F5zgPLyD3wKDE7MUPAwo5nELCEbJEELDafLeV4Uz7djSFJ",
"PUB_WA_5Q6G5dqajZDkqDbgEUG7a7qMpe94P6LegYdf7h8yL9efjhC6ERWuFsJM1ueygEmXzaELBokrUeH8",
"PUB_WA_6wUAAJXLFc3edKhGb5DdWb3WmoLxLwFSspdiEYHvT6DN2X7zo6opCfv6TcAifvxRQdVYwcr84zMS"

@learnforpractice

Btw there is a sort() helper on the Authority type that will do the locale compare hack but will break on updates that have multiple WA keys as you pointed out

@learnforpractice The issue is the WA key type, your memcmp method would likely fail as well if the array contained keys of that type since the keydata contains a string that packs its own length as a varuint prefix