Solidity based Social Auth — Sending Crypto to any Google Account

Lucas Henning
11 min readApr 3, 2023

--

Ever tried sending a Bored Ape to your grandpa? Chances are, he’s not the most crypto savvy fella. Every web3 user is familiar with the onboarding nightmare. You wouldn’t even try to explain the wallet setup to your grandpa.

We need to find ways to securely send crypto to anyone. Moreover, we need a solution that doesn’t require users to sign up. Anyone on the internet should be able to receive crypto, or — for the sake of this article — let’s say at least anyone with a Google account.

The concept of using Google Login in a smart contract is not new. This project was inspired by an early example of validating Google JWTs by OpenZeppelin. This article guides you through the process of extending OZ’s solution, eliminating the need for onboarding, and connecting a ChainLink oracle to it. The result is a smart contract-based, non-custodial lockbox that allows users to deposit ETH and ERC20 tokens that can only be accessed by the recipient’s Google account.

No onboarding required, anyone can receive → SocialLock Demo

JWhat?

Google’s authentication is based on JWTs (JSON Web Tokens). More specifically, Google uses OAuth 2.0, an open standard for authorization. If you’re not familiar with JWTs, I highly recommend https://jwt.io/.

We will use Google’s JWT to authenticate the recipient of the funds. To access the locked funds, the receiving user must sign in with their Google account and provide a valid JWT to the smart contract. In other words, the only way to withdraw ETH from the lockbox is to provide a valid JWT (signed by Google) and thus have access to the recipient’s Google account.

JWTs are a widely used industry standard and probably the most common way to handle web authentication. The specification is defined in RFC7519. You don’t need to fully understand the RFC; the important thing to know is that Google’s JWT uses the RSA256 signing mechanism. This means that the signature value of the JWT is an RSA signature of the SHA256 hash of the payload. We will need to understand this when it comes to validating the signature in the smart contract.

1. Lockbox Deposit

Let’s start with the simple part: locking funds for a given email address. This can be achieved with a simple lockup function. The contract uses a mapping to store each user’s balance.

/**
* This mapping stores the Google email address and maps it to a balance
* @dev Emails need to be keccak256 hashed before being used as a key
*/
mapping (bytes32 => uint) public balances;

/**
* Deposit ETH into the contract for a given email address
* @param email The google email address of the user (needs to be keccak256 hashed before being used as a key)
*/
function deposit(bytes32 email) public payable {
balances[email] += msg.value;
}

As you can see, the mapping uses a kecccak256(abi.encode(email)) hash of the email address. Using the plaintext email would be sufficient for the mapping to work, but it would compromise privacy to some extent. Using the keccak256 hash of the email ensures that the output length is always 32 bytes and the email is not sent in plaintext.

Caution: Please be aware that the email will still be exposed in the withdraw function as the JWT payload needs to be sent to the contract. The base64-encoded email address is part of the JWT, which means that someone could decode the data field of a withdrawal transaction to get the email. Nonetheless, using the hash is still more secure than plaintext, as it won’t expose the email without a withdrawal.

2. Lockbox Withdrawal

The recipient can unlock the funds by providing a valid JWT. The withdraw function verifies the JWT and makes sure the email in the payload matches the email in the balance mapping of the smart contract.

function withdraw(string memory headerJson, string memory payloadJson, bytes memory signature, uint amount) public {
// validate JWT
string memory email = validateJwt(headerJson, payloadJson, signature);
bytes32 emailHash = keccak256(abi.encodePacked(email));

// balance check
// this uses the email address from the Google JWT to check if there is a balance for this email
require(balances[emailHash] > 0, "No balance for this email");
require(balances[emailHash] >= amount, "Not enough balance for this email");

// transfer
balances[emailHash] -= amount;
msg.sender.transfer(amount);
}

Let’s take a closer look at the validateJwt function. It verifies the following things:

  1. JWT signature
  2. Audience — this is the google OAuth client ID
  3. Nonce — this is the recipient’s Ethereum address
function validateJwt(string memory headerJson, string memory payloadJson, bytes memory signature) internal returns (string memory) {
string memory headerBase64 = headerJson.encode();
string memory payloadBase64 = payloadJson.encode();
StringUtils.slice[] memory slices = new StringUtils.slice[](2);
slices[0] = headerBase64.toSlice();
slices[1] = payloadBase64.toSlice();
string memory message = ".".toSlice().join(slices);
string memory kid = parseHeader(headerJson);
bytes memory exponent = getRsaExponent(kid);
bytes memory modulus = getRsaModulus(kid);

// signature check
require(message.pkcs1Sha256VerifyStr(signature, exponent, modulus) == 0, "RSA signature check failed");

(string memory aud, string memory nonce, string memory sub, string memory email) = parseToken(payloadJson);

// audience check, this is the Google OAuth2 Web App clientID
require(aud.strCompare(audience) == 0, "Audience does not match");

// nonce check
// the nonce is the destination address that will receive the funds and also the address triggering this transaction (msg.sender)
// we're validating if Google JWT includes the nonce (msg.sender) in the payload
string memory senderBase64 = string(abi.encodePacked(msg.sender)).encode();
console.log('senderBase64', senderBase64);
console.log('nonce', nonce);
require(senderBase64.strCompare(nonce) == 0, "Sender does not match nonce");

return email;
}

The function verifies the JWT signature and checks whether the audience matches the Google client ID used in the constructor of the SocialLock contract. Finally, it ensures that the nonce matches the msg.sender Ethereum address. Inspired by OpenZeppelin's implementation, this nonce check prevents anyone from using the same JWT to send something to another address or trigger another transaction.

In production, more factors should be incorporated into the nonce, as this is the value that Google will sign. Ideally, the authentication provider should sign all transaction details, including the data and value fields, which would then be validated in the smart contract.

3. Oracle time — getting the RSA Modulus

The RSA signature validation requires public keys. These are exposed by Google as JSON Web Keys (JWK) in a JSON Web Key Set (JWKS). The JWKS is published here and keys are rotated every 48 hours: https://www.googleapis.com/oauth2/v3/certs

We can obtain the JWKS by using a ChainLink-based oracle. The JWT will include the kid (key ID) as well as the RSA modulus n. As defined in RFC7517, the kid is used to choose from a valid set of keys and will later be included in the JWT for validation.

/**
* @notice Request the latest JWKS keys from Google
* @dev After deployment, call this function at least once to get the latest JWKS keys
*/
function requestJwks() public returns (bytes32 requestId) {
Chainlink.Request memory req = buildChainlinkRequest(
'631ceadc6a534f9694ead93a9617706c', // Shout out to Mathias @ https://glink.solutions/ for creating and hosting this job
address(this),
this.fulfill.selector // function selector to point to the fulfill function
);
req.add("get", "https://www.googleapis.com/oauth2/v3/certs");
req.add("path1", "keys,0,kid"); // kid1
req.add("path2", "keys,0,n"); // modulus1
req.add("path3", "keys,1,kid"); // kid2
req.add("path4", "keys,1,n"); // modulus2
return sendChainlinkRequest(req, fee);
}

The oracle job used to fetch this information is not exactly standard. It requires one URL and four path parameters that will be resolved and sent back to the contract. A shoutout to Mathias from https://glink.solutions/ for creating and hosting the oracle job that calls the API for this oracle contract.

The modulus n is sent to the contract as a hex-encoded byte value, but the value itself is base64url-encoded. Decoding this value was tricky, as I couldn't find a Solidity library that could handle it. However, I was able to edit an existing library and create a decoding function. You can see it in action in the fulfill() function that will be called by the oracle.

/**
* @notice This function is called by the oracle after the request is fulfilled
* @dev The modifier recordChainlinkFulfillment prevents the function from being called by anyone except the oracle
*/
function fulfill(
bytes32 _requestId,
string memory kid1,
bytes memory modulus1,
string memory kid2,
bytes memory modulus2
) public recordChainlinkFulfillment(_requestId) {
emit FulfilledJWKS(_requestId, kid1, modulus1, kid2, modulus2);
// Decode the base64url encoded modulus
// The JWKS values are encoded according to rfc4648 (https://tools.ietf.org/html/rfc4648)
jwks[kid1] = modulus1.decode();
jwks[kid2] = modulus2.decode();
}

With the oracle setup complete, you can get the values by funding your oracle with LINK and calling the requestJwks() function to trigger an update. The oracle job will call your smart contract and update the kid and n values. You can then call the getModulus(kid) function of your contract to get the latest values. Feel free to use this deployment to try it out. You can use any kid from the Google JWKS.

Retrieving the modulus value from the oracle contract

It is worth mentioning that a successful RSA signature validation also requires the RSA exponent. However, a surprising fact about RSA is that it is common practice to always use the same number as the exponent, specifically 65537 or AQAB in hex. You will find this number in the JWKS published by Google. Since it is always the same value, we do not need an oracle to read it. We have hardcoded this value in the SocialLock.sol smart contract.

function getRsaExponent(string memory) internal pure returns (bytes memory) {
// hardcoded rsa exponent = AQAB
return hex"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010001";
}

4. Building the UI

I created a fully functional demo UI written in React/Vite. As pointed out in the deposit part of this article, the contract will store a mapping of keccak256(email) hashes to balances. So, before we deposit, we need to hash the email and get a bytes32 value to send to the contract. Doing this in JavaScript looks like this:

// matches this solidity code: bytes32 emailHash = keccak256(abi.encodePacked(email));
const encodedSender = ethers.utils.hexlify(Buffer.from(ethers.utils.toUtf8Bytes(address)));

With the hash figured out, calling the deposit function should be easy. The last challenging piece is the withdraw where every single aspect of the JWT will be checked. Messing up the nonce is easy, so make sure to get this one right. The JWT’s payload is base64url encoded. The nonce value will hold the msg.sender , the address that is triggering the withdrawal. Remember the smart contract function that we used for nonce validation?

string memory senderBase64 = string(abi.encodePacked(msg.sender)).encode();
require(senderBase64.strCompare(nonce) == 0, "Sender does not match nonce");

To build the counter part in the UI, we need to base64url encode the msg.sender . Note the additional replace() calls to make this encoding URL safe for the JWT.

// The following code base64url encodes the address to be used as the nonce in the JWT
const base64Address = ethers.utils.base64
.encode(address)
.replace('=', '')
.replace('+', '-')
.replaceAll('/', '_');

Eventually, we can include the nonce in the Google Login component:

<GoogleLogin
nonce={base64Address}
onSuccess={onLogin}
onError={ () => console.log('Login Failed') } />

After setting up your Google OAuth application and logging into your UI, decode your JWT using https://jwt.io. The payload should include a nonce that matches your address:

{
"aud": "<audience>", // verify this matches your aud
"email": "your@email.com", // verify this matches your email
// decode and verify the nonce, make sure it matches your sender
"nonce": "cJl5cMUYEtw6AQx9AbUODRfcecg", // = 0x70997970c51812dc3a010c7d01b50e0d17dc79c8
"email_verified": true,
"name": "Lucas Henning",
"iat": 1677616519,
"exp": 1677620119,
// some values removed
}

Double check the email, aud, and nonce values in your JWT payload. Anything that does not match your settings will cause the withdraw function to fail. To decode your nonce value you can use this one liner:

const nonce = "cJl5cMUYEtw6AQx9AbUODRfcecg";
const address = '0x' + Buffer.from(nonce, 'base64').toString('hex');
// address = 0x70997970c51812dc3a010c7d01b50e0d17dc79c8

Gas

It is not surprising that the cryptographic operations in these contracts consume a considerable amount of gas. In a production environment, one could move some of these operations to an off-chain processor or retrieve results from an oracle. However, the purpose of this article is to perform validation on-chain, and there are certain trust-related benefits to doing it this way.

Let’s look at the actual gas that’s needed for a withdrawal. When you clone the repo and run npx hardhat test the tests will print out the gas consumed.

gas used: BigNumber { value: "1780178" }

To give you an idea of what that means, let’s assume an average gas price of 50 Gwei (you can check the latest value at https://ethgasstation.info/).

50 gwei * 1,780,178 gas = 89,008,900 gwei
89,009,900 * 10^-9 = 0.0890099 ETH

In other words, a recovery would cost $163 on the mainnet (at $1800/ETH) or $0.09 on Polygon (at $1/MATIC). Although this may seem high, it is very unlikely for an operation like this to be executed on the main chain. Instead, it would be preferable to move it to L2 or execute it as a meta transaction.

Next Steps

Now that the onboarding problem is solved, it is important to consider the next steps. It is clear that the shown authentication mechanism should not be used as a standalone wallet. Instead, it can serve as the first factor of a multisig smart wallet that will be extended by several other signatures.

It’s also worth noting that relying on Google as an identity provider may not be acceptable to some in the crypto community. Additionally, we must trust the oracle service to provide accurate JWKS for validation. To improve trust in this system, multiple identity providers and oracles can be used. This way, the reliance on a single entity is reduced, and the accuracy of the validation can be more thoroughly verified.

In production, mechanisms like this can be used to implement social authentication as part of Account Abstraction, EIP-4337 compatible social recovery, and wallet creation for social media users. By providing easy onboarding and gradually increasing security through additional signatures, we can make cryptocurrency more accessible to everyone.

Shameless plug, check out suku.world to see some of the tools we’re building to achieve this goal.

Conclusion

We demonstrated how to create a non-custodial lockbox that allows users to securely send cryptocurrency to any Google account. Although this is still a proof of concept and not yet production-ready, we need to take steps like this to make cryptocurrency more accessible to everyone, including those who may not be familiar with the complexities of traditional wallets and onboarding processes. It’s essential to prioritize onboarding the receiver rather than the sender, and we need to develop protocols that enable anyone to receive cryptocurrency without prior knowledge or setup.

Account abstraction is a key concept in this direction, and EIP-4337 proposes a significant step forward by separating account management and transaction processing. With this improvement, we can onboard new users without requiring them to set up an Ethereum account, making cryptocurrency more user-friendly.

The good news is that with this experiment, we’re one step closer to making cryptocurrency accessible to everyone, including your grandpa.

Github Repo

https://github.com/lucashenning/solidity-google-auth

Credits

--

--

Lucas Henning
Lucas Henning

Written by Lucas Henning

CTO @ suku.world — building a more accessible web3

No responses yet