Skip to content

Simplifying authentication #60

@freesig

Description

@freesig

The feedback is that the current authentication example is too complex.
I have come up with the following options for how to simplify this but there are some challenges.
I will use the transfer token as an example because it is the most complex.

Transfer

A token transfer can either be authenticated because:

  • A predicate that owns the key
    • Currently implemented as the hash(PredicateAddress) == key
  • A signature is produced that locks in the key and some other data.
    • Ownership is hash(recover(hash(data))) == key.
    • The simplest version of this is where data == { key, to, amount, token_address, nonce' }.
    • This means that the user is locking in the key that is being transferred from, the address the transfer is going to, the amount being transferred, which token_address is being transferred and the nonce' for this transfer.
    • If the user wants to pay a solver or builder for solving or inclusion then they probably won't know the to address so they will only sign over data == { key, amount, token_address, nonce' }.
    • If the user is setting the amount based off some other constraints like amount == received / fraction then they must sign over data == { key, additional_constraints, token_address, nonce' } where additional_constraints is the address of the predicate that contains the amount constraint.
    • If the user is trying to do a swap they will not know the exact amount or to address for the outbound transfer but they will have additional constraints so they will sign over data == { key, additional_constraints, token_address, nonce' }

It's tempting to ignore all this complexity for an example token but swaps and paying solvers / builders is actually pretty common / trivial use cases that people are going to want to know how to do pretty early on. I think if we leave this out then we are just going to get feedback that our protocol can't handle this basic functionality.

There are three main options inline or transient, multiple predicates that I will detail here.

Inline

Inline means we include the signing functionality inline with the token source (probably by providing a library).
One downside is that this code needs to be deployed redundantly with every token which wastes space but isn't the biggest deal for an example.
This is easy enough to do. We simple provide an @auth() macro that the token predicate can call. The complexity comes in how to pass in the inputs to that macro.

The types we need to express this are:

// Types of authentication.
enum AuthType = Predicate | Signed;
// What is being signed
enum SignType = All | Key | KeyTo | KeyAmount;
// Address of an individual predicate
type PredicateAddress = { contract: b256, addr: b256 };
// Signature
type Secp256k1Signature = { b256, b256, int };

Product types

This relies on using sentinel values to emulate sum types within product types. So the enum "tags" say which value is "set" while the other "unset" values are set to some form of default / zeroed out value.

type Auth = {
  // Tags
  auth_type: AuthType,
  sign_type: SignType,
  require_additional_constraints: bool,
  // Data
  owning_predicate: PredicateAddress,
  sign: Secp256k1Signature,
  additional_constraints: PredicateAddress,
}

// In the transfer predicate
var auth: Auth;

@auth(auth);

One advantage is that this can be done in current pint however I think it's a little confusing and makes Pint look pretty bad. It might turn devs off using our system.
It's cleaner from the predicate POV but does waste bandwidth.

Separate dec vars

Similar to product type but each field is in it's own dec var slot.

var auth_type: AuthType;
var owning_predicate: PredicateAddress;
var sign_type: SignType;
var sig: Secp256k1Signature;
var require_additional_constraints: bool;
var additional_constraints: PredicateAddress;

@auth(auth_type; owning_predicate; sign_type; sig; require_additional_constraints; additional_constraints);

This really makes the transfer predicate messy and hard to understand. The advantage vs Product types is that you don't actually have to set the "unset" vars to a sentinel value and can leave them empty instead. This saves on bandwidth. It will probably confuse beginners and also makes Pint look bad.

Sum types

This uses actual sum types like tagged unions. This doesn't exist in current Pint and could be pretty hard to add.

enum Auth = Predicate(PredicateAddress) | Sign(Sign);
type Sign = { sign_type: SignType, signature: Secp256k1Signature, additional_constraints: AdditionalConstraints };
enum AdditionalConstraints = Predicate(PredicateAddress) | None;

// In the transfer predicate
var auth: Auth;

@auth(auth);

This saves data and is the easiest to understand. It's also cleaner as the transfer predicate only needs to know that there's this one Auth type and @auth macro from a library. The main disadvantage is that it's not possible to do in current Pint and I'm not sure if we want to add it or how hard it will be to add.

Transient

Abstracting the signing complexity to another contract means the token doesn't have to know about all these options and can just provide a list of which signing options the token supports.

interface Auth {
    predicate Predicate {
        pub var addr: PredicateAddress;
    }
}
var authorization_predicate: PredicateAddress;
interface AuthI = Auth(predicate_address.contract);
predicate A = AuthI::Predicate(authorization_predicate.addr);

constraint (@is_owning_predicate(key; authorization_predicate) || @is_in_set(authorization_predicate; auth::@transfer; auth::@transfer_with)) && A::addr.contract == __this_contract_address() && A::addr.addr == __this_address();

This avoids bringing any authorization logic into the token and just requires that the authorization_predicate is solved and is pointing at this token.

Predicates for each case

Another option is to have separate predicates for each option.

predicate TransferFromPredicate {
  // Somehow reuse common code

  var owning_predicate: PredicateAddress;
  constraint @is_owning_predicate(key; authorization_predicate);
}

predicate TransferFromSigned {
  // Somehow reuse common code

  var sig: Secp256k1Signature;
  constraint @check_sig({key, to, amount, nonce'}; sig);
}

predicate TransferFromSignedExtra {

  var sign_type: SignType;
  var sig: Secp256k1Signature;
  var additional_constraints: PredicateAddress;

  constraint @check_sig_with_additional(sign_type; sig; additional_constraints);
}

This avoids the need fro sum types (or fake sum types) but heavily infects the transfer predicate with authentication logic. It's also not easy to reuse the code that is the same between each predicate because you can't declare var or pub var within macros.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions