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

View File

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

View File

@ -172,8 +172,8 @@ describe("Keystore", () => {
}); });
it("should fail to create store from invalid string", () => { it("should fail to create store from invalid string", () => {
expect(Keystore.fromString("/asdq}")).to.eq(null); expect(Keystore.fromString("/asdq}")).to.eq(undefined);
expect(Keystore.fromString('{ "name": "it" }')).to.eq(null); expect(Keystore.fromString('{ "name": "it" }')).to.eq(undefined);
}); });
it("shoud create store from valid string", async () => { it("shoud create store from valid string", async () => {
@ -308,6 +308,6 @@ describe("Keystore", () => {
const store = Keystore.fromObject(NWAKU_KEYSTORE as any); const store = Keystore.fromObject(NWAKU_KEYSTORE as any);
const result = await store.readCredential("wrong-hash", "wrong-password"); 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 { v4 as uuidV4 } from "uuid";
import { buildBigIntFromUint8Array } from "../byte_utils.js"; import { buildBigIntFromUint8Array } from "../byte_utils.js";
import type { IdentityCredential } from "../rln.js";
import { decryptEipKeystore, keccak256Checksum } from "./cipher.js"; import { decryptEipKeystore, keccak256Checksum } from "./cipher.js";
import { isCredentialValid, isKeystoreValid } from "./schema_validator.js"; import { isCredentialValid, isKeystoreValid } from "./schema_validator.js";
import type { import type {
Keccak256Hash, Keccak256Hash,
KeystoreEntity,
MembershipHash, MembershipHash,
MembershipInfo, MembershipInfo,
Password, Password,
@ -57,11 +57,6 @@ type KeystoreCreateOptions = {
appIdentifier?: string; appIdentifier?: string;
}; };
type IdentityOptions = {
identity: IdentityCredential;
membership: MembershipInfo;
};
export class Keystore { export class Keystore {
private data: NwakuKeystore; private data: NwakuKeystore;
@ -81,7 +76,9 @@ export class Keystore {
return new Keystore(options); 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 { try {
const obj = JSON.parse(str); const obj = JSON.parse(str);
@ -92,7 +89,7 @@ export class Keystore {
return new Keystore(obj); return new Keystore(obj);
} catch (err) { } catch (err) {
console.error("Cannot create Keystore from string:", err); console.error("Cannot create Keystore from string:", err);
return null; return;
} }
} }
@ -105,7 +102,7 @@ export class Keystore {
} }
public async addCredential( public async addCredential(
options: IdentityOptions, options: KeystoreEntity,
password: Password password: Password
): Promise<MembershipHash> { ): Promise<MembershipHash> {
const membershipHash: MembershipHash = Keystore.computeMembershipHash( const membershipHash: MembershipHash = Keystore.computeMembershipHash(
@ -138,11 +135,11 @@ export class Keystore {
public async readCredential( public async readCredential(
membershipHash: MembershipHash, membershipHash: MembershipHash,
password: Password password: Password
): Promise<null | IdentityOptions> { ): Promise<undefined | KeystoreEntity> {
const nwakuCredential = this.data.credentials[membershipHash]; const nwakuCredential = this.data.credentials[membershipHash];
if (!nwakuCredential) { if (!nwakuCredential) {
return null; return;
} }
const eipKeystore = Keystore.fromCredentialToEip(nwakuCredential); const eipKeystore = Keystore.fromCredentialToEip(nwakuCredential);
@ -167,6 +164,14 @@ export class Keystore {
return this.data; 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 { private static isValidNwakuStore(obj: unknown): boolean {
if (!isKeystoreValid(obj)) { if (!isKeystoreValid(obj)) {
return false; return false;
@ -237,7 +242,7 @@ export class Keystore {
private static fromBytesToIdentity( private static fromBytesToIdentity(
bytes: Uint8Array bytes: Uint8Array
): null | IdentityOptions { ): undefined | KeystoreEntity {
try { try {
const str = bytesToUtf8(bytes); const str = bytesToUtf8(bytes);
const obj = JSON.parse(str); const obj = JSON.parse(str);
@ -271,7 +276,7 @@ export class Keystore {
}; };
} catch (err) { } catch (err) {
console.error("Cannot parse bytes to Nwaku Credentials:", err); console.error("Cannot parse bytes to Nwaku Credentials:", err);
return null; return;
} }
} }
@ -302,7 +307,7 @@ export class Keystore {
// follows nwaku implementation // follows nwaku implementation
// https://github.com/waku-org/nwaku/blob/f05528d4be3d3c876a8b07f9bb7dfaae8aa8ec6e/waku/waku_keystore/protocol_types.nim#L98 // 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( return utf8ToBytes(
JSON.stringify({ JSON.stringify({
treeIndex: options.membership.treeIndex, treeIndex: options.membership.treeIndex,

View File

@ -1,3 +1,5 @@
import type { IdentityCredential } from "../rln.js";
export type MembershipHash = string; export type MembershipHash = string;
export type Sha256Hash = string; export type Sha256Hash = string;
export type Keccak256Hash = string; export type Keccak256Hash = string;
@ -10,3 +12,25 @@ export type MembershipInfo = {
address: string; address: string;
treeIndex: number; 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,7 +1,6 @@
import { ethers } from "ethers"; import { ethers } from "ethers";
export const extractMetaMaskAccount = export const extractMetaMaskSigner = async (): Promise<ethers.Signer> => {
async (): Promise<ethers.providers.Web3Provider> => {
const ethereum = (window as any).ethereum; const ethereum = (window as any).ethereum;
if (!ethereum) { if (!ethereum) {
@ -11,5 +10,7 @@ export const extractMetaMaskAccount =
} }
await ethereum.request({ method: "eth_requestAccounts" }); await ethereum.request({ method: "eth_requestAccounts" });
return new ethers.providers.Web3Provider(ethereum, "any"); 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 { IRateLimitProof } from "@waku/interfaces";
import type {
ContentTopic,
IDecodedMessage,
EncoderOptions as WakuEncoderOptions,
} from "@waku/interfaces";
import init from "@waku/zerokit-rln-wasm"; import init from "@waku/zerokit-rln-wasm";
import * as zerokitRLN from "@waku/zerokit-rln-wasm"; import * as zerokitRLN from "@waku/zerokit-rln-wasm";
import { ethers } from "ethers"; import { ethers } from "ethers";
import { buildBigIntFromUint8Array, writeUIntLE } from "./byte_utils.js"; 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 { SEPOLIA_CONTRACT } from "./constants.js";
import { dateToEpoch, epochIntToBytes } from "./epoch.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 verificationKey from "./resources/verification_key.js";
import { RLNContract } from "./rln_contract.js"; import { RLNContract } from "./rln_contract.js";
import * as wc from "./witness_calculator.js"; import * as wc from "./witness_calculator.js";
@ -164,34 +178,180 @@ export function sha256(input: Uint8Array): Uint8Array {
type StartRLNOptions = { 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. * If not set - will use default SEPOLIA_CONTRACT address.
*/ */
registryAddress?: string; 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 { 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( constructor(
private zkRLN: number, private zkRLN: number,
private witnessCalculator: WitnessCalculator private witnessCalculator: WitnessCalculator
) {} ) {}
public get contract(): null | RLNContract { public get contract(): undefined | RLNContract {
return this._contract; return this._contract;
} }
public async start(options: StartRLNOptions = {}): Promise<void> { public get signer(): undefined | ethers.Signer {
const provider = options.provider || (await extractMetaMaskAccount()); return this._signer;
const registryAddress = options.registryAddress || SEPOLIA_CONTRACT.address; }
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, { 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, 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 voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address);
const rlnContract = new rln.RLNContract(rlnInstance, { const rlnContract = new rln.RLNContract(rlnInstance, {
registryAddress: rln.SEPOLIA_CONTRACT.address, registryAddress: rln.SEPOLIA_CONTRACT.address,
provider: voidSigner, signer: voidSigner,
}); });
rlnContract["storageContract"] = { rlnContract["storageContract"] = {
@ -32,7 +32,7 @@ describe("RLN Contract abstraction", () => {
chai.expect(insertMemberSpy).to.have.been.called(); chai.expect(insertMemberSpy).to.have.been.called();
}); });
it("should register a member by signature", async () => { it("should register a member", async () => {
const mockSignature = const mockSignature =
"0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c"; "0xdeb8a6b00a8e404deb1f52d3aa72ed7f60a2ff4484c737eedaef18a0aacb2dfb4d5d74ac39bb71fa358cf2eb390565a35b026cc6272f2010d4351e17670311c21c";
@ -40,7 +40,7 @@ describe("RLN Contract abstraction", () => {
const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address); const voidSigner = new ethers.VoidSigner(rln.SEPOLIA_CONTRACT.address);
const rlnContract = new rln.RLNContract(rlnInstance, { const rlnContract = new rln.RLNContract(rlnInstance, {
registryAddress: rln.SEPOLIA_CONTRACT.address, registryAddress: rln.SEPOLIA_CONTRACT.address,
provider: voidSigner, signer: voidSigner,
}); });
rlnContract["storageIndex"] = 1; rlnContract["storageIndex"] = 1;
@ -57,7 +57,9 @@ describe("RLN Contract abstraction", () => {
"register(uint16,uint256)" "register(uint16,uint256)"
); );
await rlnContract.registerWithSignature(rlnInstance, mockSignature); const identity =
rlnInstance.generateSeededIdentityCredential(mockSignature);
await rlnContract.registerWithIdentity(identity);
chai.expect(contractSpy).to.have.been.called(); chai.expect(contractSpy).to.have.been.called();
}); });

View File

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