Building a Decentralized Identity Verification system on Solana (Part 1)
SMS is more than 35 years old and while it was mainly designed for communication, in practice we also use our mobile phone numbers as a universal identifiers. We use thm to login to apps like Uber and Venmo and for two-step verifications at banks and social media sites.
However, phone-numbers-as-identity has several big problems:
- SIM swap attacks are very real
- SMS messages are not encrypted
- It’s expensive to get new phone numbers
- Verifications are relatively expensive: Twilio charges .75 pennies per message
- You don’t really “own” your phone number, it belongs to the telco
- Phone numbers are not “friendly” identifiers
These problems boil down into several categories: security, ownership, and cost.
A different approach: Smart Contracts and Decentralized Identifiers
In this post series we'll present one way to build a new decentralized identity and verification system that improves on phone numbers. We're building it on the Solana blockchain solely with Smart Contracts (a.k.a Solana Programs).
Why a blockchain?
It’s important to note that it is possible to build a similar system that is not on-chain — a centralized, trusted third-party service. We specifically stay away from this approach because we want the system to be provably correct and secure, and function transparently. Without a trusted third party, decentralized consensus is one approach to achieve this.
- Transaction costs are negligible
- The account programming model is powerful
- Programs can be written in Rust (or any programming language that can target the LLVM BPF backend)
- The developer experience is first class (in part, thanks frameworks like Anchor)
The idea is to replace phone numbers with friendly user identifiers, i.e. a username. Any person should be able to claim a username as their own (first come, first serve). Any service, i.e. AcmeInc, should be able to challenge any user to verify their identity. The verification flow needs to be as simple as it is for SMS:
- type in a username
- receive an auth challenge
- verify identity by responding to the challenge
To build this on chain, our smart contract needs to have several “instructions”:
In this post series we’ll cover some of the fundamentals of building in the Solana programming model and design, build and deploy a functioning program on chain that does decentralized identity verification as an attempt to offer an alternative to phone-number-as-identity.
We’ll be writing our smart contract in Rust using the Anchor Framework. Anchor provides excellent interfaces for safety and dramatically cuts down on boilerplate code.
Solana’s Programming Account Model
Accounts are how Solana programs store state on chain. Accounts are addressed by 256-bit numbers that may or may not be Ed25519 public keys. In addition to arbitrary amounts of data, Accounts also store several other important metadata attributes.
- Balance: the number of lamports in the account (1 Lamport = 0.000000001 SOL)
- Owner: an address of a program that owns this account, like the “SystemProgram”
- Executable: a boolean flag that indicates if the data in the account corresponds to a program or not
Accounts that are executable are programs. The address of an program account is often called the program id.
Transaction & Instructions
The programming model has several rules that are enforced. The most important rule for the context of this post is: “Only the owner may change account data.” This means that our smart contract is the arbiter of writing data to any account it owns.
A transaction contains one or more instructions to execute a program. Instructions specify the program to execute, data arguments for the program, and a list of accounts to read and potentially mutate. Transactions can optionally be signed by one or more private keys where the corresponding public keys must be the addresses of an account in the transaction. Programs can check if the supplied accounts are signers which enables the program verify that the instruction is authorized to perform an action.
The first instruction we need to support is claiming a username. To explain how we can do this on Solana we need to first explain what a Program Derived Address (PDA) is.
The User Address
When a username is claimed we want to create a unique Account whose owner is our smart contract program and the “authority” (or who the username is assigned to) is the public key of the first person to claim the username. We call this the User account. The User Account stores the binding between the username and the authority public key.
The core requirement this system must observe is that: usernames must be unique. Two user accounts with the same username cannot exist.
To accomplish this we can use a PDA. Namely, we derive a unique address for each username. The first user to submit a transaction that creates an account with this address is the authority for that username.
This address is not a real public key, it’s just an address namespaced to our program address space. PDAs are useful for cross-program invocation because only the program can act as a signer on behalf of this account.
The PDA generation algorithm uses a cryptographic hash function to combine one or more “seeds” — in our case just the username — with the program id. Anybody can compute PDAs on behalf of any program.
The generation algorithm specifically creates an address off of the Ed25519 curve to ensure that a corresponding private key cannot exist. The PDA algorithm returns a 256-bit number that “bumps” the hash off of the curve.
The User Account
Abstracting the user account with a PDA is useful for us because it gives us a convenient way to ensure that only a single account exists per username. More conveniently, it gives us a trivial way to derive the user account address with only the username. Given somebody’s username, I can compute their “user address” and therefore look up the user account on chain to get the user’s real public key (the “authority”).
We build our User Account data structure as follows:
The important data we’d like to store is the
authority as it binds the username to user owned public key. However, we also store the username and bump number. We do this for convenience, as we’ll later see. For example, given a user PDA we can “reverse” the hash to determine the username by looking it up on chain.
Claim Username Instruction
Now let’s build the
claim_username program instruction. The Anchor framework really starts to shine here as it abstracts away all the safety and account architecture into simple to use Rust procedural macros. Instructions boil down to two types of inputs: accounts and instruction data (a.k.a arguments).
Let’s define our
CreateUser account inputs.
CreateUser Account inputs reference 3 types of accounts:
user: this is the account we wish to create. The data stored in this account corresponds to the
UserAccountstructure as defined above.
init: indicates that the account should be created and initialized (if the account is already initialized, the instruction will fail!). Implicitly marks the account as “mutable” for this instruction.
seeds: specifies the input to the PDA derivate (as described above)
bump: the bump 256-bit number to compute the PDA with the seeds
payer: accounts need to be funded with a minimum balance to avoid deletion. This is known as rent exemption and the payer is the account that’s used to fund the rent balance.
space: account creation must specify how many bytes it occupies (used for rent calculation). Here we reference the constant we defined that precomputes the space we’ll need. We limit each username to at most 140 chars.
owner: the owner of this account is our program’s
system_program: the system program is used to create accounts and assign ownership of the account to our program. We specify this as the Anchor framework generates code to automatically make the cross-program invocation to the system program to create our user account.
authority: this is the user’s own wallet account which will have “authority” over the username in the User Account. This account is used to fund the
mut: indicates this account must be “mutable” because we need to subtract lamports from it. The system program mutates this account to pay for the rent exemption in the user account creation.
signer: indicates that this account must sign the overall transaction with the private key. This accomplishes two things for us: (1) the system program requires the signature to pay for account creation and (2) our own program wants to ensure the user claiming a username actually owns the public key they’re binding it to.
Note that the
#[instruction(...) attribute is syntactic sugar for referencing the instruction data arguments so they can be used in the loading logic for the account inputs.
Anchor shines by automatically taking care of “loading” accounts so that they are accessible to us. Accounts are initialized (if specified) or loaded based on the attributes/mechanisms specified in the procedural macro.
Now we can specify the actual
claim_username instruction program.
Note that most of the work is done by the time the code in the function above gets executed. This is because Anchor’s procedural macro is really generating lines of code to do account creation and initialization and all sorts of safety checks like account ownership and signature verification.
The function above is very simple, all it does is set the fields in the UserAccount data structure with the data we’ve already verified. If the username has previously been claimed, the program would have error-ed long before as the user account could not be initialized.
That’s it for part 1! We’ve built the foundational smart contract to claim usernames and bind usernames to self-sovereign public keys. In part 2, we’ll build “Service” accounts and show how they can be used to create an SMS-style challenge-response verification scheme on-chain using usernames instead of phone numbers.