feat!: add new operations and improve start (#93)

* featimprove start, types, import/export, add checks for netwrok, shortcuts for decoder encoder

* fix type issue

* update tests

* provide ability to use Keystore as a seed for credentials

* fix types

* up test

* initialize keystore by default

* add keys operation to Keystore
This commit is contained in:
Sasha 2024-01-30 22:28:12 +01:00 committed by GitHub
parent bafbe01e52
commit 77ba0a6d5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 261 additions and 66 deletions

View File

@ -6,6 +6,7 @@ import {
} from "./constants.js";
import { createRLN } from "./create.js";
import { Keystore } from "./keystore/index.js";
import { extractMetaMaskSigner } from "./metamask.js";
import {
IdentityCredential,
Proof,
@ -29,4 +30,5 @@ export {
RLN_STORAGE_ABI,
RLN_REGISTRY_ABI,
SEPOLIA_CONTRACT,
extractMetaMaskSigner,
};

View File

@ -1,3 +1,5 @@
import { Keystore } from "./keystore.js";
import type { DecryptedCredentials, EncryptedCredentials } from "./types.js";
export { Keystore };
export type { EncryptedCredentials, DecryptedCredentials };

View File

@ -172,8 +172,8 @@ describe("Keystore", () => {
});
it("should fail to create store from invalid string", () => {
expect(Keystore.fromString("/asdq}")).to.eq(null);
expect(Keystore.fromString('{ "name": "it" }')).to.eq(null);
expect(Keystore.fromString("/asdq}")).to.eq(undefined);
expect(Keystore.fromString('{ "name": "it" }')).to.eq(undefined);
});
it("shoud create store from valid string", async () => {
@ -308,6 +308,6 @@ describe("Keystore", () => {
const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
const result = await store.readCredential("wrong-hash", "wrong-password");
expect(result).to.eq(null);
expect(result).to.eq(undefined);
});
});

View File

@ -14,12 +14,12 @@ import _ from "lodash";
import { v4 as uuidV4 } from "uuid";
import { buildBigIntFromUint8Array } from "../byte_utils.js";
import type { IdentityCredential } from "../rln.js";
import { decryptEipKeystore, keccak256Checksum } from "./cipher.js";
import { isCredentialValid, isKeystoreValid } from "./schema_validator.js";
import type {
Keccak256Hash,
KeystoreEntity,
MembershipHash,
MembershipInfo,
Password,
@ -57,11 +57,6 @@ type KeystoreCreateOptions = {
appIdentifier?: string;
};
type IdentityOptions = {
identity: IdentityCredential;
membership: MembershipInfo;
};
export class Keystore {
private data: NwakuKeystore;
@ -81,7 +76,9 @@ export class Keystore {
return new Keystore(options);
}
public static fromString(str: string): Keystore | null {
// should be valid JSON string that contains Keystore file
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/keyfile.nim#L376
public static fromString(str: string): undefined | Keystore {
try {
const obj = JSON.parse(str);
@ -92,7 +89,7 @@ export class Keystore {
return new Keystore(obj);
} catch (err) {
console.error("Cannot create Keystore from string:", err);
return null;
return;
}
}
@ -105,7 +102,7 @@ export class Keystore {
}
public async addCredential(
options: IdentityOptions,
options: KeystoreEntity,
password: Password
): Promise<MembershipHash> {
const membershipHash: MembershipHash = Keystore.computeMembershipHash(
@ -138,11 +135,11 @@ export class Keystore {
public async readCredential(
membershipHash: MembershipHash,
password: Password
): Promise<null | IdentityOptions> {
): Promise<undefined | KeystoreEntity> {
const nwakuCredential = this.data.credentials[membershipHash];
if (!nwakuCredential) {
return null;
return;
}
const eipKeystore = Keystore.fromCredentialToEip(nwakuCredential);
@ -167,6 +164,14 @@ export class Keystore {
return this.data;
}
/**
* Read array of hashes of current credentials
* @returns array of keys of credentials in current Keystore
*/
public keys(): string[] {
return Object.keys(this.toObject().credentials || {});
}
private static isValidNwakuStore(obj: unknown): boolean {
if (!isKeystoreValid(obj)) {
return false;
@ -237,7 +242,7 @@ export class Keystore {
private static fromBytesToIdentity(
bytes: Uint8Array
): null | IdentityOptions {
): undefined | KeystoreEntity {
try {
const str = bytesToUtf8(bytes);
const obj = JSON.parse(str);
@ -271,7 +276,7 @@ export class Keystore {
};
} catch (err) {
console.error("Cannot parse bytes to Nwaku Credentials:", err);
return null;
return;
}
}
@ -302,7 +307,7 @@ export class Keystore {
// follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L98
private static fromIdentityToBytes(options: IdentityOptions): Uint8Array {
private static fromIdentityToBytes(options: KeystoreEntity): Uint8Array {
return utf8ToBytes(
JSON.stringify({
treeIndex: options.membership.treeIndex,

View File

@ -1,3 +1,5 @@
import type { IdentityCredential } from "../rln.js";
export type MembershipHash = string;
export type Sha256Hash = string;
export type Keccak256Hash = string;
@ -10,3 +12,25 @@ export type MembershipInfo = {
address: string;
treeIndex: number;
};
export type KeystoreEntity = {
identity: IdentityCredential;
membership: MembershipInfo;
};
export type DecryptedCredentials = KeystoreEntity;
export type EncryptedCredentials = {
/**
* Valid JSON string that contains Keystore
*/
keystore: string;
/**
* ID of credentials from provided Keystore to use
*/
id: string;
/**
* Password to decrypt credentials provided
*/
password: Password;
};

View File

@ -1,15 +1,16 @@
import { ethers } from "ethers";
export const extractMetaMaskAccount =
async (): Promise<ethers.providers.Web3Provider> => {
const ethereum = (window as any).ethereum;
export const extractMetaMaskSigner = async (): Promise<ethers.Signer> => {
const ethereum = (window as any).ethereum;
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
if (!ethereum) {
throw Error(
"Missing or invalid Ethereum provider. Please install a Web3 wallet such as MetaMask."
);
}
await ethereum.request({ method: "eth_requestAccounts" });
return new ethers.providers.Web3Provider(ethereum, "any");
};
await ethereum.request({ method: "eth_requestAccounts" });
const provider = new ethers.providers.Web3Provider(ethereum, "any");
return provider.getSigner();
};

View File

@ -1,12 +1,26 @@
import { createDecoder, createEncoder } from "@waku/core";
import type { IRateLimitProof } from "@waku/interfaces";
import type {
ContentTopic,
IDecodedMessage,
EncoderOptions as WakuEncoderOptions,
} from "@waku/interfaces";
import init from "@waku/zerokit-rln-wasm";
import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { ethers } from "ethers";
import { buildBigIntFromUint8Array, writeUIntLE } from "./byte_utils.js";
import type { RLNDecoder, RLNEncoder } from "./codec.js";
import { createRLNDecoder, createRLNEncoder } from "./codec.js";
import { SEPOLIA_CONTRACT } from "./constants.js";
import { dateToEpoch, epochIntToBytes } from "./epoch.js";
import { extractMetaMaskAccount } from "./metamask.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
EncryptedCredentials,
} from "./keystore/index.js";
import { Password } from "./keystore/types.js";
import { extractMetaMaskSigner } from "./metamask.js";
import verificationKey from "./resources/verification_key.js";
import { RLNContract } from "./rln_contract.js";
import * as wc from "./witness_calculator.js";
@ -164,34 +178,180 @@ export function sha256(input: Uint8Array): Uint8Array {
type StartRLNOptions = {
/**
* If not set - will extract MetaMask account and get provider from it.
* If not set - will extract MetaMask account and get signer from it.
*/
provider?: ethers.providers.Provider;
signer?: ethers.Signer;
/**
* If not set - will use default SEPOLIA_CONTRACT address.
*/
registryAddress?: string;
/**
* Credentials to use for generating proofs and connecting to the contract and network.
* If provided used for validating the network chainId and connecting to registry contract.
*/
credentials?: EncryptedCredentials | DecryptedCredentials;
};
type RegisterMembershipOptions =
| { signature: string }
| { identity: IdentityCredential };
export class RLNInstance {
private _contract: null | RLNContract = null;
private started = false;
private starting = false;
private _contract: undefined | RLNContract;
private _signer: undefined | ethers.Signer;
private keystore = Keystore.create();
private _credentials: undefined | DecryptedCredentials;
constructor(
private zkRLN: number,
private witnessCalculator: WitnessCalculator
) {}
public get contract(): null | RLNContract {
public get contract(): undefined | RLNContract {
return this._contract;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
const provider = options.provider || (await extractMetaMaskAccount());
const registryAddress = options.registryAddress || SEPOLIA_CONTRACT.address;
public get signer(): undefined | ethers.Signer {
return this._signer;
}
this._contract = await RLNContract.init(this, {
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
return;
}
this.starting = true;
try {
const { signer, registryAddress } = await this.determineStartOptions(
options
);
this._signer = signer!;
this._contract = await RLNContract.init(this, {
registryAddress: registryAddress!,
signer: signer!,
});
this.started = true;
} finally {
this.starting = false;
}
}
private async determineStartOptions(
options: StartRLNOptions
): Promise<StartRLNOptions> {
const credentials = await this.decryptCredentialsIfNeeded(
options.credentials
);
let chainId = credentials?.membership.chainId;
const registryAddress =
credentials?.membership.address ||
options.registryAddress ||
SEPOLIA_CONTRACT.address;
if (registryAddress === SEPOLIA_CONTRACT.address) {
chainId = SEPOLIA_CONTRACT.chainId;
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
if (chainId && chainId !== currentChainId) {
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
registryAddress,
provider,
credentials: options.credentials,
};
}
private async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<undefined | DecryptedCredentials> {
if (!credentials) {
return;
}
if ("identity" in credentials) {
this._credentials = credentials;
return credentials;
}
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
throw Error("Failed to start RLN: cannot read Keystore provided.");
}
this.keystore = keystore;
this._credentials = await keystore.readCredential(
credentials.id,
credentials.password
);
return this._credentials;
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
throw Error("RLN Contract is not initialized.");
}
let identity = "identity" in options && options.identity;
if ("signature" in options) {
identity = await this.generateSeededIdentityCredential(options.signature);
}
if (!identity) {
throw Error("Missing signature or identity to register membership.");
}
return this.contract.registerWithIdentity(identity);
}
/**
* Changes credentials in use by relying on provided Keystore earlier in rln.start
* @param id: string, hash of credentials to select from Keystore
* @param password: string or bytes to use to decrypt credentials from Keystore
*/
public async useCredentials(id: string, password: Password): Promise<void> {
this._credentials = await this.keystore?.readCredential(id, password);
}
public createEncoder(options: WakuEncoderOptions): RLNEncoder {
if (!this._credentials) {
throw Error(
"Failed to create Encoder: missing RLN credentials. Use createRLNEncoder directly."
);
}
return createRLNEncoder({
encoder: createEncoder(options),
rlnInstance: this,
index: this._credentials.membership.treeIndex,
credential: this._credentials.identity,
});
}
public createDecoder(
contentTopic: ContentTopic
): RLNDecoder<IDecodedMessage> {
return createRLNDecoder({
rlnInstance: this,
decoder: createDecoder(contentTopic),
});
}

View File

@ -16,7 +16,7 @@ describe("RLN Contract abstraction", () => {
const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address);
const rlnContract = new rln.RLNContract(rlnInstance, {
registryAddress: rln.SEPOLIA_CONTRACT.address,
provider: voidSigner,
signer: voidSigner,
});
rlnContract["storageContract"] = {
@ -32,7 +32,7 @@ describe("RLN Contract abstraction", () => {
chai.expect(insertMemberSpy).to.have.been.called();
});
it("should register a member by signature", async () => {
it("should register a member", async () => {
const mockSignature =
"0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c";
@ -40,7 +40,7 @@ describe("RLN Contract abstraction", () => {
const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address);
const rlnContract = new rln.RLNContract(rlnInstance, {
registryAddress: rln.SEPOLIA_CONTRACT.address,
provider: voidSigner,
signer: voidSigner,
});
rlnContract["storageIndex"] = 1;
@ -57,7 +57,9 @@ describe("RLN Contract abstraction", () => {
"register(uint16,uint256)"
);
await rlnContract.registerWithSignature(rlnInstance, mockSignature);
const identity =
rlnInstance.generateSeededIdentityCredential(mockSignature);
await rlnContract.registerWithIdentity(identity);
chai.expect(contractSpy).to.have.been.called();
});

View File

@ -3,7 +3,8 @@ import { ethers } from "ethers";
import { zeroPadLE } from "./byte_utils.js";
import { RLN_REGISTRY_ABI, RLN_STORAGE_ABI } from "./constants.js";
import { IdentityCredential, RLNInstance } from "./rln.js";
import type { DecryptedCredentials } from "./keystore/index.js";
import { type IdentityCredential, RLNInstance } from "./rln.js";
import { MerkleRootTracker } from "./root_tracker.js";
type Member = {
@ -11,10 +12,10 @@ type Member = {
index: ethers.BigNumber;
};
type Provider = ethers.Signer | ethers.providers.Provider;
type Signer = ethers.Signer;
type RLNContractOptions = {
provider: Provider;
signer: Signer;
registryAddress: string;
};
@ -47,7 +48,7 @@ export class RLNContract {
): Promise<RLNContract> {
const rlnContract = new RLNContract(rlnInstance, options);
await rlnContract.initStorageContract(options.provider);
await rlnContract.initStorageContract(options.signer);
await rlnContract.fetchMembers(rlnInstance);
rlnContract.subscribeToMembers(rlnInstance);
@ -56,20 +57,20 @@ export class RLNContract {
constructor(
rlnInstance: RLNInstance,
{ registryAddress, provider }: RLNContractOptions
{ registryAddress, signer }: RLNContractOptions
) {
const initialRoot = rlnInstance.getMerkleRoot();
this.registryContract = new ethers.Contract(
registryAddress,
RLN_REGISTRY_ABI,
provider
signer
);
this.merkleRootTracker = new MerkleRootTracker(5, initialRoot);
}
private async initStorageContract(
provider: Provider,
signer: Signer,
options: RLNStorageOptions = {}
): Promise<void> {
const storageIndex = options?.storageIndex
@ -85,7 +86,7 @@ export class RLNContract {
this.storageContract = new ethers.Contract(
storageAddress,
RLN_STORAGE_ABI,
provider
signer
);
this._membersFilter = this.storageContract.filters.MemberRegistered();
@ -207,19 +208,9 @@ export class RLNContract {
);
}
public async registerWithSignature(
rlnInstance: RLNInstance,
signature: string
): Promise<Member | undefined> {
const identityCredential =
await rlnInstance.generateSeededIdentityCredential(signature);
return this.registerWithKey(identityCredential);
}
public async registerWithKey(
credential: IdentityCredential
): Promise<Member | undefined> {
public async registerWithIdentity(
identity: IdentityCredential
): Promise<DecryptedCredentials | undefined> {
if (this.storageIndex === undefined) {
throw Error(
"Cannot register credential, no storage contract index found."
@ -228,7 +219,7 @@ export class RLNContract {
const txRegisterResponse: ethers.ContractTransaction =
await this.registryContract["register(uint16,uint256)"](
this.storageIndex,
credential.IDCommitmentBigInt,
identity.IDCommitmentBigInt,
{ gasLimit: 100000 }
);
const txRegisterReceipt = await txRegisterResponse.wait();
@ -245,9 +236,17 @@ export class RLNContract {
memberRegistered.data
);
const network = await this.registryContract.provider.getNetwork();
const address = this.registryContract.address;
const membershipId = decodedData.index.toNumber();
return {
idCommitment: decodedData.idCommitment,
index: decodedData.index,
identity,
membership: {
address,
treeIndex: membershipId,
chainId: network.chainId,
},
};
}