near / nearcore

Reference client for NEAR Protocol
https://near.org
GNU General Public License v3.0
2.31k stars 619 forks source link

Support app-specific accounts #687

Closed evgenykuzyakov closed 5 years ago

evgenykuzyakov commented 5 years ago

To support mobile apps on NEAR, we need to implement app-specific keys in some form or another. Mobile apps don't have a good way to seamlessly communicating with the wallet to sign transactions without exposing them to the app (especially on iOS). You either have to use server-side communication, or you need to bring the wallet to the foreground.

One way of doing it is to create an app-specific account and then give keys to the app. Here is how it works: 0) Your account is account_id . 1) You create a new account for the app. Let's say new_acc_id. 2) You lend/lock tokens on the new_acc_id to use your account account_id. Now new_acc_id can only send transactions to your original account. The amount defines the limit on how many transactions it can send. 2) You authorize your account's contract to proxy transactions from new_acc_id with restrictions, e.g., non-monetary and only to specific contracts. You can also add additional GAS/MANA to the transaction. In this case, you can minimize the amount of tokens you need to lend new_acc_id. (This change requires a new API to WASM and work on accounting, we need to discuss as part of economy model). 3) You pass keys for the new_acc_id to the app (maybe encrypting them first with the app's public key to avoid leaking on the way). 4) App uses the keys and proxies transactions through your real account.

ilblackdragon commented 5 years ago

Before we implement this, let's think if there is a simpler solution. For example, adding a field to TX "app" signed with key of which can have different logic (charge the app, on account allow "app" to do stuff, etc).

ilblackdragon commented 5 years ago

@evgenykuzyakov I suggest two options:

  1. Add flags to public keys in the list. E.g. format is
    
    enum AccessKey {
    FullAccess(PublicKey), // this key has full access
    MultiSig(Vec<PublicKey>, u32), // only if x out of total keys have signed
    DeployKey(PublicKey), // only can deploy
    AppKey(PublicKey, AccountId), // only can use app with `AccountId`
    // other options.
    }

struct Account { amount: u256, access_keys: Vec, code_hash: CryptoHash }



2. Go directly to contract-based accounts, where we just have a function `check` which has enough gas for max 5 signature verifications + a bit of logic. This function is getting called for each transaction, when we accept it. It also can work as permissioning for what this transaction can do (deploy vs spend money, etc)

What do you think?
evgenykuzyakov commented 5 years ago

Option 2 sounds good if the account runs shared code. I'd still prefer if we charge gas for the validation and we need to figure out how to charge gas for large code size preparation. Currently we assume that instance spawn is free and don't cost any gas.

We'd also have to move public keys out of Account structure, since deserialization of all public keys would be expensive. WASM code should know which public key was used and we can do this by passing it as an argument.

We'd also have to either expose some more data to WASM through read api or serialize the TX info into some format. It would be really hard to replace that standard format in the future. Which brings a question about JSON vs protobuf again.

Problems (for option 2):

Maybe we can have merged approach:

You have 2 types of keys:

struct AccessKey {
  /// Owner's account_id
  owner_id: AccountId,
  /// signature required for this key
  public_key: PublicKey,
  /// amount of funding on this key to pay for gas
  amount: u256,
  /// owner of the funding on this key, e.g. if app wants to pay for the user. So user can't steal funds
  funding_owner: AccountId,
  /// account_id of the contract towards which this access key can be used.
  /// It's also can be owner's account_id and be used for rate limiting and proxying.
  contract_id: AccountId,
}
bowenwang1996 commented 5 years ago

We can limit the number of public keys an account can have. Also @ilblackdragon said that we should charge gas for verification but have a fixed limit of how much gas you can use there to discourage people from writing their own functions.

evgenykuzyakov commented 5 years ago

We can limit the number of public keys an account can have.

You don't want to limit app specific keys since there are can be 100s of apps enabled at once, like on mobile

Let's go through problems:

App may spam with valid transactions and drain account from gas (since it doesn't require a user to be online)

With access_key app can only send as many transaction as funding allows. Unless the developer pays for this, then it's fine.

Should we charge for verification to prevent invalid txs spam attack? If so, should we charge account or the specific key? (We already have AccountingInfo that can do proper accounting)

Yep, we're charging access_key for gas. Public key would be verified similar to standard account verification, no overhead. If the access key points to the owners account, then owners account would just proxy the call (similar to option 2), but we don't limit the runtime with specific API on how to approve transactions. There are also no additional hops needed (as in my original proposal). But we need to expose public key of the signature to WASM. It's much easier to do, just one more data_type within read. AccessKeys could be auto-refunded daily to support some type of protection and rate limiting.

Too many keys on a single account would slow down account deserialization. Needs to refactor public keys out of the account.

No need to refactor public keys out, but need to support access key TX verification. In theory we can move everything to access keys if we want to.

Some standard on the function api to pass info for verification. It would be hard to change this in the future, since both runtime, wallet and wasm code has to be aware of the version.

No need to have a standard for this, but need to expose public key to have proper proxy support when you call your own account using access_key.

vgrichina commented 5 years ago

I don't really have much too add except: 1) I prefer hardcoded to contract-based, cause it's likely easier to make it happen and optimize it to work fast. 2) As this gonna end up changing the way keys are stored – let's make sure we aren't limited to ed25519 signatures. I think we should at least support Ethereum-style signatures + whatever Apple supports in their hardware. That would greatly simplify wallet integrations. Basically Ethereum-style signature support means we can integrate with whatever Web3 provider. Fortmatic, Portis, Metamask, whatever.

ilblackdragon commented 5 years ago

AccessKeys can be stored under account_id,public_key to make lookup cheap. Right now we actually need to iterate over all public keys on the account to validate signature will all of them. Alternatively tx has public_key that is used for signature and then it's just one lookup.

One side note, is that if application needs to refresh keys, it will need to update all of the AccessKeys for all applications. @evgenykuzyakov here points that app should not store it on the server side, but this works not for all use cases.

High level, access keys are allowing to prepay for specific key to do more custom validation. @evgenykuzyakov suggests it can execute custom code from user account for additional validation.

E.g. if we want to have vesting on the account, we can implement it:

"illia.near": {amount: 100, public_keys: [], code: vesting contract} "illia.near,primary_key": {amount: 10, function: "has_vested", funding_owner: "", contract_id: ""}

Additional question is that we need to top of the fees on the access keys.

One question I still have, that given we are currently checking signatures on the account, how much slower would that be if the same thing was done in WASM?

ilblackdragon commented 5 years ago

To previous note - don't need to store sender's public key in transaction, because there is a way to recover public key from signature + hash (may be only for Eth sigs like SECP256K1 though)

evgenykuzyakov commented 5 years ago

I don't think we need to restrict function that can be called, even though it's an interesting idea and we probably should do this.

I was thinking you'd create a proxy function on your account that either creates a async call or dies. Here is the pseudo code:


let accessParams = collections.map<string, AccessParams>("ap");

function proxyTx(tx: Transaction): void {
  // Create ContractPromise based on the tx and returns it.
}

function assertAccess(tx: Transaction, ap: AccessParams): void {
  // Verifies parameters of transaction and ap
}

export function proxy(tx: Transaction): void {
  // Called by our account (through access key probably)
  assert(context.sender == context.contract_id);
  // Identify access key
  const pk = context.public_key;
  // Check PK access params
  let ap = accessParams.get(pk);
  assert(ap != null);
  assertAccess(tx, ap);
  proxyTx(tx);
}

You'd also have a function to modify access params on your account that would be called from the wallet to issue access params and control apps.

ilblackdragon commented 5 years ago

@evgenykuzyakov So the last part with the proxy on the account via access key already works or not yet?

evgenykuzyakov commented 5 years ago

For proxy call, we need to expose public_key to context.

ilblackdragon commented 5 years ago

@evgenykuzyakov is this done now?