js-waku/packages/rln/src/credentials_manager.ts
2025-08-27 23:32:41 +10:00

297 lines
9.1 KiB
TypeScript

import { hmac } from "@noble/hashes/hmac";
import { sha256 } from "@noble/hashes/sha2";
import { Logger } from "@waku/utils";
import { ethers } from "ethers";
import { RLN_CONTRACT, RLN_Q } from "./contract/constants.js";
import { RLNBaseContract } from "./contract/rln_base_contract.js";
import { IdentityCredential } from "./identity.js";
import { Keystore } from "./keystore/index.js";
import type {
DecryptedCredentials,
EncryptedCredentials
} from "./keystore/index.js";
import { KeystoreEntity, Password } from "./keystore/types.js";
import { RegisterMembershipOptions, StartRLNOptions } from "./types.js";
import { BytesUtils } from "./utils/bytes.js";
import { extractMetaMaskSigner } from "./utils/index.js";
import { Zerokit } from "./zerokit.js";
const log = new Logger("rln:credentials");
/**
* Manages credentials for RLN
* This is a lightweight implementation of the RLN contract that doesn't require Zerokit
* It is used to register membership and generate identity credentials
*/
export class RLNCredentialsManager {
protected started = false;
protected starting = false;
public contract: undefined | RLNBaseContract;
public signer: undefined | ethers.Signer;
protected keystore = Keystore.create();
public credentials: undefined | DecryptedCredentials;
public zerokit: undefined | Zerokit;
public constructor(zerokit?: Zerokit) {
log.info("RLNCredentialsManager initialized");
this.zerokit = zerokit;
}
public get provider(): undefined | ethers.providers.Provider {
return this.contract?.provider;
}
public async start(options: StartRLNOptions = {}): Promise<void> {
if (this.started || this.starting) {
log.info("RLNCredentialsManager already started or starting");
return;
}
log.info("Starting RLNCredentialsManager");
this.starting = true;
try {
const { credentials, keystore } =
await RLNCredentialsManager.decryptCredentialsIfNeeded(
options.credentials
);
if (credentials) {
log.info("Credentials successfully decrypted");
}
const { signer, address, rateLimit } = await this.determineStartOptions(
options,
credentials
);
log.info(`Using contract address: ${address}`);
if (keystore) {
this.keystore = keystore;
log.info("Using provided keystore");
}
this.credentials = credentials;
this.signer = signer!;
this.contract = await RLNBaseContract.create({
address: address!,
signer: signer!,
rateLimit: rateLimit ?? this.zerokit?.rateLimit
});
log.info("RLNCredentialsManager successfully started");
this.started = true;
} catch (error) {
log.error("Failed to start RLNCredentialsManager", error);
throw error;
} finally {
this.starting = false;
}
}
public async registerMembership(
options: RegisterMembershipOptions
): Promise<undefined | DecryptedCredentials> {
if (!this.contract) {
log.error("RLN Contract is not initialized");
throw Error("RLN Contract is not initialized.");
}
log.info("Registering membership");
let identity = "identity" in options && options.identity;
if ("signature" in options) {
log.info("Generating identity from signature");
if (this.zerokit) {
log.info("Using Zerokit to generate identity");
identity = this.zerokit.generateSeededIdentityCredential(
options.signature
);
} else {
log.info("Using local implementation to generate identity");
identity = await this.generateSeededIdentityCredential(
options.signature
);
}
}
if (!identity) {
log.error("Missing signature or identity to register membership");
throw Error("Missing signature or identity to register membership.");
}
log.info("Registering identity with contract");
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> {
log.info(`Attempting to use credentials with ID: ${id}`);
this.credentials = await this.keystore?.readCredential(id, password);
if (this.credentials) {
log.info("Successfully loaded credentials");
} else {
log.warn("Failed to load credentials");
}
}
protected async determineStartOptions(
options: StartRLNOptions,
credentials: KeystoreEntity | undefined
): Promise<StartRLNOptions> {
let chainId = credentials?.membership.chainId;
const address =
credentials?.membership.address ||
options.address ||
RLN_CONTRACT.address;
if (address === RLN_CONTRACT.address) {
chainId = RLN_CONTRACT.chainId.toString();
log.info(`Using Linea contract with chainId: ${chainId}`);
}
const signer = options.signer || (await extractMetaMaskSigner());
const currentChainId = await signer.getChainId();
log.info(`Current chain ID: ${currentChainId}`);
if (chainId && chainId !== currentChainId.toString()) {
log.error(
`Chain ID mismatch: contract=${chainId}, current=${currentChainId}`
);
throw Error(
`Failed to start RLN contract, chain ID of contract is different from current one: contract-${chainId}, current network-${currentChainId}`
);
}
return {
signer,
address
};
}
protected static async decryptCredentialsIfNeeded(
credentials?: EncryptedCredentials | DecryptedCredentials
): Promise<{ credentials?: DecryptedCredentials; keystore?: Keystore }> {
if (!credentials) {
log.info("No credentials provided");
return {};
}
if ("identity" in credentials) {
log.info("Using already decrypted credentials");
return { credentials };
}
log.info("Attempting to decrypt credentials");
const keystore = Keystore.fromString(credentials.keystore);
if (!keystore) {
log.warn("Failed to create keystore from string");
return {};
}
try {
const decryptedCredentials = await keystore.readCredential(
credentials.id,
credentials.password
);
log.info(`Successfully decrypted credentials with ID: ${credentials.id}`);
return {
keystore,
credentials: decryptedCredentials
};
} catch (error) {
log.error("Failed to decrypt credentials", error);
throw error;
}
}
protected async verifyCredentialsAgainstContract(
credentials: KeystoreEntity
): Promise<void> {
if (!this.contract) {
throw Error(
"Failed to verify chain coordinates: no contract initialized."
);
}
const registryAddress = credentials.membership.address;
const currentRegistryAddress = this.contract.address;
if (registryAddress !== currentRegistryAddress) {
throw Error(
`Failed to verify chain coordinates: credentials contract address=${registryAddress} is not equal to registryContract address=${currentRegistryAddress}`
);
}
const chainId = credentials.membership.chainId;
const network = await this.contract.provider.getNetwork();
const currentChainId = network.chainId;
if (chainId !== currentChainId.toString()) {
throw Error(
`Failed to verify chain coordinates: credentials chainID=${chainId} is not equal to registryContract chainID=${currentChainId}`
);
}
}
/**
* Generates an identity credential from a seed string
* This is a pure implementation that doesn't rely on Zerokit
* @param seed A string seed to generate the identity from
* @returns IdentityCredential
*/
private async generateSeededIdentityCredential(
seed: string
): Promise<IdentityCredential> {
log.info("Generating seeded identity credential");
// Convert the seed to bytes
const encoder = new TextEncoder();
const seedBytes = encoder.encode(seed);
// Generate deterministic values using HMAC-SHA256
// We use different context strings for each component to ensure they're different
const idTrapdoorBE = hmac(sha256, seedBytes, encoder.encode("IDTrapdoor"));
const idNullifierBE = hmac(
sha256,
seedBytes,
encoder.encode("IDNullifier")
);
const combinedBytes = new Uint8Array([...idTrapdoorBE, ...idNullifierBE]);
const idSecretHashBE = sha256(combinedBytes);
const idCommitmentRawBE = sha256(idSecretHashBE);
const idCommitmentBE = this.reduceIdCommitment(idCommitmentRawBE);
log.info(
"Successfully generated identity credential, storing in Big Endian format"
);
return new IdentityCredential(
idTrapdoorBE,
idNullifierBE,
idSecretHashBE,
idCommitmentBE
);
}
/**
* Helper: take 32-byte BE, reduce mod Q, return 32-byte BE
*/
private reduceIdCommitment(
bytesBE: Uint8Array,
limit: bigint = RLN_Q
): Uint8Array {
const nBE = BytesUtils.buildBigIntFromUint8ArrayBE(bytesBE);
return BytesUtils.bigIntToUint8Array32BE(nBE % limit);
}
}