Managing Authorization Methods For A PKP that Owns Itself
This guide covers how to add and remove Authentication Methods for a Programmable Key Pair (PKP) that owns itself. For an overview of what a PKP is, please go here.
When a PKP is created, an ERC-721 NFT is minted and ownership of it is granted to the address whom minted it by default (there is an option to assign ownership to another address on mint, but that's not covered by this guide). The owner of the NFT is the designated "controller" of the PKP, and is the entity that is authorized to use the PKP.
In order for the "controller" of a PKP to be able to use it, they must first authenticate with the Lit network (i.e. verify who they say they are), so the Lit network can verify they're authorized to use the PKP. Typically, when the owner of the PKP is an Externally Owned Account (EOA), they authenticate themselves by signing a Sign-in With Ethereum (ERC-4361) message. However, there are multiple other Authentication Methods that can be used such as:
Another Authentication Method that is available to use with PKPs is Custom Authentication. This is possible by creating a Lit Action, uploading it to IPFS, and then adding the IPFS Content Identifier (CID) as a permitted Authentication Method for the PKP. Because Lit Actions are JavaScript scripts, arbitrary steps can be performed to authenticate an entity using Custom Authentication. This allows for Authentication Methods that use custom logic such as fetch
ing data from an API, or even implementing a Social Login that's not supported natively by Lit.
In this guide we're going to cover an example implementation of adding and removing Authentication Methods when the "controller" of the PKP is itself. This is different from when an EOA is the "controller" because we need to authenticate as the PKP by signing a message, but we can only sign a message with the PKP once we're authenticated and checked for authorization to use the PKP.
Prerequisites
Before continuing with this guide, you should have an understanding of:
Example Implementation
When a PKP owns itself, it enables a more decentralized approach, as there is no "admin address" that has direct control of the PKP. Instead, the authentication and authorization is handled by alternative Authentication Methods that make use of the systems mentioned above.
The full code implementation can be found here.
Overview
In this example implementation, EOA_A
will mint a PKP, add a Custom Authentication Method to authorize itself to use the PKP, and transfer ownership of the PKP to the PKP's Ethereum address. It will then use this Custom Authentication Method to generate PKP Session Signatures, which allows it to add another Custom Authentication Method for authorizing EOA_B
to use the PKP. Subsequently, EOA_B
will use the second Custom Authentication Method to remove the first Custom Authentication Method (the one that authorized EOA_A to use the PKP) as a permitted authentication method for the PKP, effectively removing EOA_A
’s control over the PKP.
To accomplish the above, the example implementation takes the following steps:
- Mint a new PKP using an EOA
- Provided as the environment variable:
process.env.ETHEREUM_PRIVATE_KEY_A
- Provided as the environment variable:
- Add a Custom Authentication Method to the PKP that authenticates a signed SIWE message and verifies it's signed by
ETHEREUM_PRIVATE_KEY_A
- This is done by adding a Lit Action as a permitted Authentication Method for the PKP by uploading our Lit Action code to IPFS
- Verify the Custom Authentication Method is permitted to be used for the PKP
- Transfer ownership of the PKP NFT from
ETHEREUM_PRIVATE_KEY_A
to the PKP's Ethereum address - Get PKP Session Signatures using the Custom Authentication Method from step 2
- Transfer some test Lit tokens from
ETHEREUM_PRIVATE_KEY_A
to the PKP's Ethereum address- This is necessary because we're interacting with the PKP Permissions contract on Lit's Chronicle chain to add and remove Authentication Methods, and we're required to pay gas for these onchain transactions
- Initialize and connect a PKP Ethers Wallet using the PKP Session Signatures from step 5
- Send a transaction to Chronicle to add a second Custom Authentication Method that authenticates a signed SIWE message and verifies it's signed by
ETHEREUM_PRIVATE_KEY_B
- This transaction executes the
addPermittedAction
function on the PKP Permissions contract
- This transaction executes the
- Verify the Custom Authentication Method is permitted to be used for the PKP
- Get PKP Session Signatures using the Custom Authentication Method from step 8
- Initialize and connect a PKP Ethers Wallet using the PKP Session Signatures from step 10
- Send a transaction to Chronicle to remove the Custom Authentication Method from step 2 as a permitted method to be used with the PKP
- Verify the Custom Authentication Method from step 2 is no longer permitted
Installing the Required Dependencies
- npm
- yarn
npm install \
@lit-protocol/auth-helpers \
@lit-protocol/constants \
@lit-protocol/contracts-sdk \
@lit-protocol/lit-node-client \
@lit-protocol/pkp-ethers \
bs58 \
ethers@v5
yarn add \
@lit-protocol/auth-helpers \
@lit-protocol/constants \
@lit-protocol/contracts-sdk \
@lit-protocol/lit-node-client \
@lit-protocol/pkp-ethers \
bs58 \
ethers@v5
Creating the Custom Authentication Lit Actions
Before continuing with the rest of the implementation, you need to have the IPFS Content Identifiers (CID) for the two Lit Actions that will be added to the PKP as permitted Authentication Methods.
As an example, below is the Lit Action code this guide will be using for the Custom Authentication Methods. These Lit Actions are taking the following steps:
- Creates an Access Control Conditions that check for a specific address
- Uses the checkConditions method from the Lit Actions API to derive an address from the provided Authentication Signature, then uses the Access Control Conditions to test if the derived address matches what's expected
- If the derived address doesn't match, setResponse is used to notify the user that they're not authorized. If the derived address matches,
setResponse
returns"true"
.- This guide covers this further in the below sections, but the Lit Action must return
"true"
as astring
in order for getLitActionSessionSigs to return the Session Signatures
- This guide covers this further in the below sections, but the Lit Action must return
- Ethereum Address A Lit Action
- Ethereum Address B Lit Action
(async () => {
const accessControlConditions = [
{
contractAddress: "",
standardContractType: "",
chain: "ethereum",
method: "",
parameters: [":userAddress"],
returnValueTest: {
comparator: "=",
value: "ETHEREUM_ADDRESS_A", // <-- Replace with your Ethereum address A
},
},
];
const testResult = await Lit.Actions.checkConditions({
conditions: accessControlConditions,
authSig: JSON.parse(authSig),
chain: "ethereum",
});
if (!testResult) {
LitActions.setResponse({
response: "Address is not authorized",
});
return;
}
LitActions.setResponse({
response: "true",
});
})();
(async () => {
const accessControlConditions = [
{
contractAddress: "",
standardContractType: "",
chain: "ethereum",
method: "",
parameters: [":userAddress"],
returnValueTest: {
comparator: "=",
value: "ETHEREUM_ADDRESS_B", // <-- Replace with your Ethereum address B
},
},
];
const testResult = await Lit.Actions.checkConditions({
conditions: accessControlConditions,
authSig: JSON.parse(authSig),
chain: "ethereum",
});
if (!testResult) {
LitActions.setResponse({
response: "Address is not authorized",
});
return;
}
LitActions.setResponse({
response: "true",
});
})();
accessControlConditions
const accessControlConditions = [
{
contractAddress: "",
standardContractType: "",
chain: "ethereum",
method: "",
parameters: [":userAddress"],
returnValueTest: {
comparator: "=",
value: "ETHEREUM_ADDRESS", // <-- Replace with Ethereum address A or B
},
},
];
If you're following this guide, you should replace ETHEREUM_ADDRESS_A
and ETHEREUM_ADDRESS_B
with the addresses corresponding to the ENVs ETHEREUM_PRIVATE_KEY_A
and ETHEREUM_PRIVATE_KEY_B
. Then you'll want to upload the Lit Action code to IPFS (Pinata provides this service), and copy the IPFS CID for both Lit Actions and set the values for their respective ENVs: LIT_ACTION_CHECK_ADDRESS_A
and LIT_ACTION_CHECK_ADDRESS_B
.
Instantiating the Ethers Signers
The ETHEREUM_PRIVATE_KEY_A
and ETHEREUM_PRIVATE_KEY_B
environment variables are required. ETHEREUM_PRIVATE_KEY_A
should have Lit test tokens on the datil-dev
network (if you need some the faucet is here).
These Ethers signers will be used to provide Authentication Signatures to the getLitActionSessionSigs
function that will provide PKP Session Signatures used to add and remove Custom Authentication Methods for the PKP.
import * as ethers from 'ethers';
const ethersSignerA = new ethers.Wallet(
process.env.ETHEREUM_PRIVATE_KEY_A,
new ethers.providers.JsonRpcProvider(
"https://chain-rpc.litprotocol.com/http"
)
);
const ethersSignerB = new ethers.Wallet(
process.env.ETHEREUM_PRIVATE_KEY_B,
new ethers.providers.JsonRpcProvider(
"https://chain-rpc.litprotocol.com/http"
)
);
Instantiating a LitContractsClient
Here we are instantiating an instance of LitContractsClient
and connecting it to the datil-dev
Lit test network. This client is how we'll mint a new PKP and add and remove permitted Authentication Methods for the PKP.
ethersSignerA
is being set as the transaction signer for the contracts client. It will be used to sign and broadcast transaction to the datil-dev
network to update the PKP Permissions contract, and will be charged Lit test tokens as gas for these transactions.
import { LitContracts } from "@lit-protocol/contracts-sdk";
const litContracts = new LitContracts({
signer: ethersSignerA,
network: 'datil-dev',
debug: false,
});
await litContracts.connect();
Mint a New PKP
Next we mint a new PKP - this is the PKP we'll be adding and removing Custom Authentication Methods from.
const mintedPkp = (await litContracts.pkpNftContractUtils.write.mint()).pkp;
mintedPkp
will be an object containing the properties:
{
tokenId: any;
publicKey: string;
ethAddress: string;
}
Where
tokenId
is the id for the PKP NFTpublicKey
is the PKP's public keyethAddress
is the Ethereum address derived frompublicKey
We'll be using the tokenId
to add and remove the Authentication Methods.
Adding the First Custom Authentication Method
Now that we have a PKP, we're going to add the Lit Action that authorizes ETHEREUM_PRIVATE_KEY_A
to use the PKP:
import { AuthMethodScope } from "@lit-protocol/constants";
const addAuthMethodAReceipt = await litContracts.addPermittedAction({
pkpTokenId: mintedPkp.tokenId,
ipfsId: process.env.LIT_ACTION_CHECK_ADDRESS_A,
authMethodScopes: [AuthMethodScope.SignAnything],
});
pkpTokenId
is the id of the PKP NFT we got from Minting a new PKPipfsId
is the IPFS CID of the Custom Authentication Lit Action that authorizesETHEREUM_PRIVATE_KEY_A
to use the PKPauthMethodScopes
is what we're allowing the Custom Authentication Method to do- Here we're allowing it to
SignAnything
which means it can sign arbitrary data which is required to produce PKP Session Signatures
- Here we're allowing it to
(Optional) Verify First Custom Authentication Method is Permitted
This step is optional, but here we're verifying that process.env.LIT_ACTION_CHECK_ADDRESS_A
was added as a permitted Authentication Method for the new PKP:
import bs58 from "bs58";
const LIT_ACTION_A_IPFS_CID_BYTES = `0x${Buffer.from(
bs58.decode(process.env.LIT_ACTION_CHECK_ADDRESS_A)
).toString("hex")}`;
let isPermittedA =
await litContracts.pkpPermissionsContract.read.isPermittedAction(
mintedPkp.tokenId,
LIT_ACTION_A_IPFS_CID_BYTES
);
if (!isPermittedA)
throw new Error("Lit Action Auth Method A is not permitted for the PKP");
Transferring PKP Ownership to Itself
Because ownership of the PKP is determined by what address owns it's corresponding NFT, we can transfer ownership by simply transferring the NFT to a new address:
const transferPkpOwnershipReceipt = await (
await litContracts.pkpNftContract.write.transferFrom(
ethersSignerA.address,
mintedPkp.ethAddress,
mintedPkp.tokenId,
{
gasLimit: 125_000,
}
)
).wait();
Here we're transferring the NFT from ethersSignerA.address
to mintedPkp.ethAddress
. After this transaction is included in a block, ETHEREUM_PRIVATE_KEY_A
will no longer be the "controller" of the PKP and will need to obtain PKP Session Signature to use the PKP using the Custom Authentication Method added previously.
Instantiating a LitNodeClient
Next we instantiate an instance of LitNodeClient
and connecting it to the datil-dev
Lit network.
import { LitNodeClient } from "@lit-protocol/lit-node-client";
const litNodeClient = new LitNodeClient({
litNetwork: 'datil-dev',
debug: false,
});
await litNodeClient.connect();
Generating PKP Session Signatures Using the First Custom Authentication Method
Because we've transferred ownership of the PKP from ETHEREUM_PRIVATE_KEY_A
to itself, ETHEREUM_PRIVATE_KEY_A
can no longer use the PKP directly. Instead we have to send a request to the Lit network to generate Session Signatures using our PKP. To do this, we have to authenticate with an Authentication Method so the Lit network can check if we're authorized to the PKP. Below we're using the Custom Authentication Method for authorizing ETHEREUM_PRIVATE_KEY_A
:
import {
LitAbility,
LitActionResource,
LitPKPResource,
createSiweMessageWithRecaps,
generateAuthSig,
} from "@lit-protocol/auth-helpers";
const pkpSessionSigsA = await litNodeClient.getLitActionSessionSigs({
pkpPublicKey: mintedPkp.publicKey,
resourceAbilityRequests: [
{
resource: new LitPKPResource("*"),
ability: LitAbility.PKPSigning,
},
{
resource: new LitActionResource("*"),
ability: LitAbility.LitActionExecution,
},
],
litActionIpfsId: process.env.LIT_ACTION_CHECK_ADDRESS_A,
jsParams: {
authSig: JSON.stringify(
await generateAuthSig({
signer: ethersSignerA,
// @ts-ignore
toSign: await createSiweMessageWithRecaps({
uri: "http://localhost",
expiration: new Date(
Date.now() + 1000 * 60 * 60 * 24
).toISOString(), // 24 hours
walletAddress: ethersSignerA.address,
nonce: await litNodeClient.getLatestBlockhash(),
litNodeClient,
}),
})
),
},
});
pkpPublicKey
This is the public key of the PKP use to generate the Session SignaturesresourceAbilityRequests
This is an array of Lit Resources and Abilities that we're allowing anyone with the PKP Session Signature to use the PKP for- Above we're requesting the ability to sign arbitrary data and execute Lit Actions with the PKP
litActionIpfsId
This is the IPFS CID for the Custom Authentication Method we permitted previously that authorizes the corresponding Ethereum address forETHEREUM_PRIVATE_KEY_A
jsParams
This object contains parameters that will be made available to the Lit Action while it's executing- The keys for each property will be the name of the variable within the Lit Action, and the values will be assigned automatically to each variable
- Above we're generating an Authentication Signature using
ethersSignerA
,JSON.stringify
ing the resulting object, and providing it to be used with the Lit Action as the variableauthSig
- The Lit Action will derive an address from the
authSig
variable, and will compare it against the Custom Authentication Method's Access Control Conditions to determine whether or not the address is permitted to use the PKP
After getLitActionSessionSigs
successfully execute, we will have Session Signatures produces by the Lit network using our PKP, authorizing us to request the Lit network to sign data with the PKP, and execute Lit Actions.
Funding the PKP
Because adding and removing Authentication Methods are onchain transactions, and the PKP is the only authorized entity to add and remove methods, we need to send the PKP some Lit test tokens to be able to add or remove methods:
import * as ethers from "ethers";
const fundPkpTxReceipt = await (
await ethersSignerA.sendTransaction({
to: mintedPkp.ethAddress,
value: ethers.utils.parseEther("0.0001"),
})
).wait();
Here we're simply transferring tokens from the corresponding Ethereum address for ETHEREUM_PRIVATE_KEY_A
, to the PKP's Ethereum address.
Instantiating a PKP Ethers Wallet Using pkpSessionSigsA
Because we transferred ownership of the PKP NFT to itself, it's the only authorized entity to make any changes to the PKP Permission contracts that governs permitted Authentication methods. So in order to make any changes to the permitted Authentication Methods, we need to sign a transaction using the PKP.
To do this we're going to make use of the PKPEthersWallet
export from the @lit-protocol/pkp-ethers
package:
import { PKPEthersWallet } from "@lit-protocol/pkp-ethers";
const pkpEthersWalletA = new PKPEthersWallet({
litNodeClient,
pkpPubKey: mintedPkp.publicKey,
controllerSessionSigs: pkpSessionSigsA,
});
await pkpEthersWalletA.init();
litNodeClient
is the client we instantiated in the Instantiating aLitNodeClient
sectionpkpPubKey
is the public key for the PKP we're going to modifying permitted Authentication Methods forcontrollerSessionSigs
is the PKP Session Signatures we generated in the Generating PKP Session Signatures Using a Lit Action section- These Session Signatures authorizes us with the Lit network to sign a transaction using the PKP
Instantiating a LitContractClient
with pkpSessionSigsA
We previously instantiated a LitContractClient
in the Instantiating a LitContractClient
section, but we passed ethersSignerA
as the signer for transactions.
Now we're going to instantiate another LitContractClient
instance, this time using the PKP Ethers Wallet as the signer:
import { LitContracts } from "@lit-protocol/contracts-sdk";
const litContractsPkpSignerA = new LitContracts({
signer: pkpEthersWalletA,
network: 'datil-dev',
debug: false,
});
await litContractsPkpSignerA.connect();
Adding the Second Custom Authentication Method
Using the LitContractClient
instance with the PKP signer, we can now sign a transaction to add the second Custom Authentication Method (process.env.LIT_ACTION_CHECK_ADDRESS_B
) that authorizes the corresponding Ethereum address for ETHEREUM_PRIVATE_KEY_B
to use the PKP:
import bs58 from "bs58";
const LIT_ACTION_B_IPFS_CID_BYTES = `0x${Buffer.from(
bs58.decode(process.env.LIT_ACTION_CHECK_ADDRESS_B)
).toString("hex")}`;
const addAuthMethodBReceipt = await (
await litContractsPkpSignerA.pkpPermissionsContract.write.addPermittedAction(
mintedPkp.tokenId,
LIT_ACTION_B_IPFS_CID_BYTES,
[AuthMethodScope.SignAnything],
{
gasPrice: await ethersSignerA.provider.getGasPrice(),
gasLimit: 250_000,
}
)
).wait();
(Optional) Verify Second Custom Authentication Method is Permitted
This step is optional, but here we're verifying that process.env.LIT_ACTION_CHECK_ADDRESS_B
was added as a permitted Authentication Method for the new PKP:
import bs58 from "bs58";
const LIT_ACTION_B_IPFS_CID_BYTES = `0x${Buffer.from(
bs58.decode(process.env.LIT_ACTION_CHECK_ADDRESS_B)
).toString("hex")}`;
const isPermittedB =
await litContracts.pkpPermissionsContract.read.isPermittedAction(
mintedPkp.tokenId,
LIT_ACTION_B_IPFS_CID_BYTES
);
if (!isPermittedB)
throw new Error("Lit Action Auth Method B is not permitted for the PKP");
Generating PKP Session Signatures Using the Second Custom Authentication Method
Similar to when we generating PKP Session Signatures using the first Custom Authentication Method, we're going to create new PKP Session Signatures using ETHEREUM_PRIVATE_KEY_B
as the authenticated entity:
Notice we're using ethersSignerB
to sign the authSig
passed into the Lit Action.
import {
LitAbility,
LitActionResource,
LitPKPResource,
createSiweMessageWithRecaps,
generateAuthSig,
} from "@lit-protocol/auth-helpers";
const pkpSessionSigsB = await litNodeClient.getLitActionSessionSigs({
pkpPublicKey: mintedPkp.publicKey,
resourceAbilityRequests: [
{
resource: new LitPKPResource("*"),
ability: LitAbility.PKPSigning,
},
{
resource: new LitActionResource("*"),
ability: LitAbility.LitActionExecution,
},
],
litActionIpfsId: process.env.LIT_ACTION_CHECK_ADDRESS_B,
jsParams: {
authSig: JSON.stringify(
await generateAuthSig({
signer: ethersSignerB,
// @ts-ignore
toSign: await createSiweMessageWithRecaps({
uri: "http://localhost",
expiration: new Date(
Date.now() + 1000 * 60 * 60 * 24
).toISOString(), // 24 hours
walletAddress: ethersSignerB.address,
nonce: await litNodeClient.getLatestBlockhash(),
litNodeClient,
}),
})
),
},
});
Instantiating a PKP Ethers Wallet Using pkpSessionSigsB
Also similar to Instantiating a PKP Ethers Wallet Using pkpSessionSigsB
, we create a PKPEthersWallet
instance, but with pkpSessionSigsB
as the controllerSessionSigs
:
import { PKPEthersWallet } from "@lit-protocol/pkp-ethers";
const pkpEthersWalletB = new PKPEthersWallet({
litNodeClient,
pkpPubKey: mintedPkp.publicKey,
controllerSessionSigs: pkpSessionSigsB,
});
await pkpEthersWalletB.init();
Instantiating a LitContractClient
with pkpSessionSigsB
Similar to Instantiating a LitContractClient
with pkpSessionSigsA
, we create an instance using pkpEthersWalletB
:
import { LitContracts } from "@lit-protocol/contracts-sdk";
const litContractsPkpSignerB = new LitContracts({
signer: pkpEthersWalletB,
network: 'datil-dev',
debug: false,
});
await litContractsPkpSignerB.connect();
Removing the First Custom Authentication Method
Now that we have two Custom Authentication Methods, one for authorizing ETHEREUM_PRIVATE_KEY_A
's Ethereum address and another for authorizing ETHEREUM_PRIVATE_KEY_B
's address, we can remove one of them:
import bs58 from "bs58";
const LIT_ACTION_A_IPFS_CID_BYTES = `0x${Buffer.from(
bs58.decode(process.env.LIT_ACTION_CHECK_ADDRESS_A)
).toString("hex")}`;
const removeAuthMethodAReceipt = await (
await litContractsPkpSignerB.pkpPermissionsContract.write.removePermittedAction(
mintedPkp.tokenId,
LIT_ACTION_A_IPFS_CID_BYTES,
{
gasPrice: await ethersSignerA.provider.getGasPrice(),
gasLimit: 100_000,
}
)
).wait();
The above is creating a transaction to the PKP Permission Contract to call the removePermittedAction
function with the IPFS CID of process.env.LIT_ACTION_CHECK_ADDRESS_A
. After this transaction is included in a block, ETHEREUM_PRIVATE_KEY_A
's Ethereum address will no longer be authorized to use the PKP - including using it to generate PKP Session Signatures.
(Optional) Verify First Custom Authentication Method is Not Permitted
This step is optional, but here we're verifying that process.env.LIT_ACTION_CHECK_ADDRESS_A
was removed as a permitted Authentication Method for the PKP:
import bs58 from "bs58";
const LIT_ACTION_A_IPFS_CID_BYTES = `0x${Buffer.from(
bs58.decode(process.env.LIT_ACTION_CHECK_ADDRESS_A)
).toString("hex")}`;
isPermittedA =
await litContracts.pkpPermissionsContract.read.isPermittedAction(
mintedPkp.tokenId,
LIT_ACTION_A_IPFS_CID_BYTES
);
if (isPermittedA)
throw new Error(
"Lit Action Auth Method A is still permitted for the PKP when it's supposed to have been removed"
);
Summary
The full code implementation can be found here.
After executing the example implementation above, you will have completed all the steps covered in the Overview section. You will have a PKP that currently only has process.env.LIT_ACTION_CHECK_ADDRESS_B
as a permitted Authentication Method, meaning only ETHEREUM_PRIVATE_KEY_B
's Ethereum address can be authorized to use the PKP.