UNPKG

@provablehq/sdk

Version:

A Software Development Kit (SDK) for Zero-Knowledge Transactions

1,221 lines (1,212 loc) 439 kB
'use strict'; require('core-js/proposals/json-parse-with-source.js'); var testnet_js = require('@provablehq/wasm/testnet.js'); var nobleSodium = require('@serenity-kit/noble-sodium'); var base = require('@scure/base'); const LEVEL_PRIORITY = { silent: 0, error: 1, warn: 2, info: 3, debug: 4, }; let currentLevel = "info"; /** * Set the SDK log level. Levels are hierarchical: * - "silent" — suppress all SDK logging * - "error" — only errors * - "warn" — errors + warnings * - "info" — errors + warnings + info (default) * - "debug" — everything including debug traces * * This controls both TS-side and WASM-side logging. WASM log calls * (e.g., "Loading program", "Executing program") are silenced when * the level is below "info". */ function setLogLevel(level) { currentLevel = level; Promise.resolve().then(function () { return require('./wasm.cjs'); }) .then(({ setWasmLogLevel }) => setWasmLogLevel(LEVEL_PRIORITY[level])) .catch(() => { }); // WASM not loaded yet — will use default level (info) } /** Returns the current SDK log level. */ function getLogLevel() { return currentLevel; } function shouldLog(level) { return LEVEL_PRIORITY[level] <= LEVEL_PRIORITY[currentLevel]; } /** * SDK logger object following console.log/warn/error/debug syntax. * Respects the current log level set via setLogLevel(). */ const logger = { log(...args) { if (shouldLog("info")) console.log(...args); }, warn(...args) { if (shouldLog("warn")) console.warn(...args); }, error(...args) { if (shouldLog("error")) console.error(...args); }, debug(...args) { if (shouldLog("debug")) console.debug(...args); }, }; /** * Error thrown when a key locator is invalid for filesystem use. * Used to prevent path traversal and other filesystem injection when deriving paths from locators. * * @extends Error */ class InvalidLocatorError extends Error { locator; reason; /** * @param message - Human-readable description of the validation failure. * @param locator - The invalid locator string that failed validation. * @param reason - Machine-readable reason code for the failure. */ constructor(message, locator, reason) { super(message); this.locator = locator; this.reason = reason; this.name = "InvalidLocatorError"; Object.setPrototypeOf(this, InvalidLocatorError.prototype); } } /** * Error thrown when there is a mismatch between expected and actual key metadata. * This can occur during verification of either the checksum or size of a key. * * @extends Error */ class KeyVerificationError extends Error { locator; field; expected; actual; /** * Creates a new KeyVerificationError instance (error.name is "ChecksumMismatchError"). * * @param {string} locator - The key locator where the mismatch occurred. * @param {"checksum" | "size"} field - The field that failed verification (either "checksum" or "size"). * @param {string} expected - The expected value of the field. * @param {string} actual - The actual value encountered. */ constructor(locator, field, expected, actual) { super(`Key verification ${locator} ${field} mismatch: expected ${expected}, got ${actual}`); this.locator = locator; this.field = field; this.expected = expected; this.actual = actual; this.name = "ChecksumMismatchError"; Object.setPrototypeOf(this, KeyVerificationError.prototype); } } /** * Computes the SHA-256 checksum of a given set of bytes. * * @param {Uint8Array} bytes - The bytes to compute the checksum of. */ async function sha256Hex(bytes) { const hash = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } /** * In-memory implementation of KeyVerifier that stores and verifies key fingerprints. * Provides functionality to compute and verify cryptographic checksums of keys, storing them * in memory for subsequent verification. This implementation is primarily used for testing * and development purposes where persistence is not required. * * Key features: * - Computes SHA-256 checksums and sizes for key bytes. * - Stores key fingerprints in memory using string locators. * - Verifies key bytes against stored or provided fingerprints. * * @implements {KeyVerifier} */ class MemKeyVerifier { keyStore = {}; /** * Computes and optionally stores key metadata. If a keyFingerprint is provided, this function will verify the computed checksum against it before storing it. * * @param {KeyMetadata} keyMetadata - Object containing key bytes and optional verification data. * @throws {KeyVerificationError} When provided keyFingerprint doesn't match computed values. * @returns {Promise<KeyFingerprint>} Computed key metadata. */ async computeKeyMetadata(keyMetadata) { // Compute the metadata from the key bytes const computedFingerprint = { checksum: await sha256Hex(keyMetadata.keyBytes), size: keyMetadata.keyBytes.length }; // If a KeyFingerprint is provided, verify it matches computed values. if (keyMetadata.fingerprint) { if (keyMetadata.fingerprint.size !== computedFingerprint.size) { throw new KeyVerificationError(keyMetadata.locator ? keyMetadata.locator : "", "size", String(keyMetadata.fingerprint.size), String(computedFingerprint.size)); } if (keyMetadata.fingerprint.checksum !== computedFingerprint.checksum) { throw new KeyVerificationError(keyMetadata.locator ? keyMetadata.locator : "", "checksum", keyMetadata.fingerprint.checksum, computedFingerprint.checksum); } } // If locator is provided, store only the fingerprint if (keyMetadata.locator) { this.keyStore[keyMetadata.locator] = computedFingerprint; } return computedFingerprint; } /** * Verifies key bytes against stored or provided metadata. Follows a priority verification scheme: * 1. If KeyFingerprint is provided in the metadata, this method verifies against that first. * 2. If a locator is provided, attempts to verify against stored fingerprint. * 3. If neither is available, throws an error. * * @param {KeyMetadata} keyMetadata - Object containing the key bytes and optional verification metadata. * @throws {Error} When neither fingerprint nor valid locator is provided for verification. * @throws {KeyVerificationError} When size or checksum verification fails. * @returns {Promise<void>} Promise that resolves when verification succeeds. */ async verifyKeyBytes(keyMetadata) { if (!keyMetadata.keyBytes) { throw new Error("Key bytes must be provided for verification."); } // Compute the fingerprint for the provided bytes. const computedFingerprint = await this.computeKeyMetadata({ keyBytes: keyMetadata.keyBytes }); // Determine which fingerprint to verify against. let fingerprintToVerify; if (keyMetadata.fingerprint) { // If a key fingerprint is provided, use it. fingerprintToVerify = keyMetadata.fingerprint; } else if (keyMetadata.locator) { // Otherwise try to get stored fingerprint by locator. fingerprintToVerify = this.keyStore[keyMetadata.locator]; } if (!fingerprintToVerify) { throw new Error("Either fingerprint or a valid locator must be provided for verification."); } // Verify the key size. if (fingerprintToVerify.size !== computedFingerprint.size) { throw new KeyVerificationError(keyMetadata.locator || "", "size", String(fingerprintToVerify.size), String(computedFingerprint.size)); } // Verify the key checksum. if (fingerprintToVerify.checksum !== computedFingerprint.checksum) { throw new KeyVerificationError(keyMetadata.locator || "", "checksum", fingerprintToVerify.checksum, computedFingerprint.checksum); } } } /** * Browser-compatible {@link KeyStore} backed by IndexedDB. * * This is the browser counterpart to {@link LocalFileKeyStore} (which requires Node.js `fs`). * It persists proving and verifying keys across page reloads and browser sessions using the * IndexedDB API available in all modern browsers and Web Workers. * * **Environment**: Browser / Web Worker only. Instantiating this class is safe in any * environment, but the first IndexedDB operation will reject with a clear error when * `globalThis.indexedDB` is not available (e.g., Node.js, SSR contexts). Guard your * imports accordingly if you bundle for server-side rendering. * * **Security**: Keys are stored as plaintext `Uint8Array` in IndexedDB. Any script * running on the same origin — including scripts injected via XSS — can read the stored * proving and verifying keys. Do **not** use this keystore for data that must remain * confidential under an XSS-capable adversary; encrypt sensitive material at the * application layer before persisting, or use an in-memory store. * * @example * ```ts * import { IndexedDBKeyStore, ProgramManager } from "@provablehq/sdk"; * * const keyStore = new IndexedDBKeyStore(); * const pm = new ProgramManager(); * pm.setKeyStore(keyStore); * // Keys synthesized during execution are now cached in IndexedDB * // and reloaded automatically on subsequent runs. * ``` */ class IndexedDBKeyStore { dbName; storeName = "keys"; keyVerifier = new MemKeyVerifier(); dbPromise = null; /** * @param dbName IndexedDB database name. Defaults to `"aleo-keystore"`. */ constructor(dbName = "aleo-keystore") { this.dbName = dbName; } // ------------------------------------------------------- // IndexedDB helpers // ------------------------------------------------------- /** Opens (or creates) the database, returning a cached promise. */ openDB() { if (this.dbPromise) return this.dbPromise; // Env check sits outside the Promise constructor so a failure doesn't // poison the cache; a later call (e.g. after a polyfill loads) can retry. if (typeof indexedDB === "undefined") { return Promise.reject(new Error("IndexedDBKeyStore requires a browser or Web Worker environment: " + "`indexedDB` is not defined. If you are in Node.js or an SSR " + "context, use LocalFileKeyStore or an in-memory key provider instead.")); } this.dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName, { keyPath: "locator" }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => { this.dbPromise = null; reject(request.error); }; request.onblocked = () => { this.dbPromise = null; reject(new DOMException("Database open blocked", "AbortError")); }; }); return this.dbPromise; } /** Runs a single read-write transaction and returns the request result. */ async tx(mode, fn) { const db = await this.openDB(); return new Promise((resolve, reject) => { const txn = db.transaction(this.storeName, mode); const store = txn.objectStore(this.storeName); const req = fn(store); let result; req.onsuccess = () => { result = req.result; }; txn.oncomplete = () => resolve(result); txn.onerror = () => reject(txn.error); txn.onabort = () => reject(txn.error ?? new DOMException("Transaction aborted", "AbortError")); }); } // ------------------------------------------------------- // Locator serialization (mirrors LocalFileKeyStore) // ------------------------------------------------------- validateComponent(value, label) { if (value === "" || value === ".") { throw new InvalidLocatorError(`KeyLocator ${label} must not be empty or "." (got "${value}")`, value, "reserved_name"); } if (value.includes("..")) { throw new InvalidLocatorError(`KeyLocator ${label} must not contain ".." (got "${value}")`, value, "path_traversal"); } if (value.includes("/") || value.includes("\\") || value.includes("\0")) { throw new InvalidLocatorError(`KeyLocator ${label} must not contain path separators or null bytes (got "${value}")`, value, "path_separator"); } } validateNonNegative(value, label) { if (!Number.isInteger(value) || value < 0) { throw new InvalidLocatorError(`KeyLocator ${label} must be a non-negative integer (got ${value})`, String(value), "negative_value"); } } serializeLocator(locator) { this.validateComponent(locator.program, "program"); this.validateComponent(locator.functionName, "functionName"); this.validateComponent(locator.network, "network"); this.validateNonNegative(locator.edition, "edition"); this.validateNonNegative(locator.amendment, "amendment"); const base = `${locator.program}.${locator.functionName}.e${locator.edition}.a${locator.amendment}.${locator.network}.${locator.keyType}`; if (locator.keyType === "translation") { this.validateComponent(locator.recordName, "recordName"); this.validateNonNegative(locator.recordInputPosition, "recordInputPosition"); return `${base}.${locator.recordName}.${locator.recordInputPosition}`; } return base; } checksumToFingerprint(checksum, keyBytes) { if (!checksum) return undefined; return { checksum, size: keyBytes.length }; } // ------------------------------------------------------- // KeyStore interface // ------------------------------------------------------- async getKeyBytes(locator) { const key = this.serializeLocator(locator); const record = await this.tx("readonly", (store) => store.get(key)); if (!record) return null; const fingerprint = this.checksumToFingerprint(locator.checksum, record.bytes) ?? record.metadata; if (fingerprint) { await this.keyVerifier.verifyKeyBytes({ keyBytes: record.bytes, locator: key, fingerprint, }); } return record.bytes; } async getProvingKey(locator) { const bytes = await this.getKeyBytes(locator); if (!bytes) return null; return testnet_js.ProvingKey.fromBytes(bytes); } async getVerifyingKey(locator) { const bytes = await this.getKeyBytes(locator); if (!bytes) return null; return testnet_js.VerifyingKey.fromBytes(bytes); } async setKeys(proverLocator, verifierLocator, keys) { const proverKey = this.serializeLocator(proverLocator); const verifierKey = this.serializeLocator(verifierLocator); const [provingKey, verifyingKey] = keys; const [provingKeyBytes, verifyingKeyBytes] = [ provingKey.toBytes(), verifyingKey.toBytes(), ]; const [proverFingerprint, verifierFingerprint] = await Promise.all([ this.keyVerifier.computeKeyMetadata({ keyBytes: provingKeyBytes, locator: proverKey, fingerprint: this.checksumToFingerprint(proverLocator.checksum, provingKeyBytes), }), this.keyVerifier.computeKeyMetadata({ keyBytes: verifyingKeyBytes, locator: verifierKey, fingerprint: this.checksumToFingerprint(verifierLocator.checksum, verifyingKeyBytes), }), ]); const proverRecord = { locator: proverKey, bytes: provingKeyBytes, metadata: proverFingerprint }; const verifierRecord = { locator: verifierKey, bytes: verifyingKeyBytes, metadata: verifierFingerprint }; // Write both in a single transaction for atomicity. const db = await this.openDB(); await new Promise((resolve, reject) => { const txn = db.transaction(this.storeName, "readwrite"); const store = txn.objectStore(this.storeName); store.put(proverRecord); store.put(verifierRecord); txn.oncomplete = () => resolve(); txn.onerror = () => reject(txn.error); txn.onabort = () => reject(txn.error ?? new DOMException("Transaction aborted", "AbortError")); }); } async setKeyBytes(keyBytes, locator) { const key = this.serializeLocator(locator); const computedMetadata = await this.keyVerifier.computeKeyMetadata({ keyBytes, locator: key, fingerprint: this.checksumToFingerprint(locator.checksum, keyBytes), }); const record = { locator: key, bytes: keyBytes, metadata: computedMetadata }; await this.tx("readwrite", (store) => store.put(record)); } async getKeyMetadata(locator) { const key = this.serializeLocator(locator); const record = await this.tx("readonly", (store) => store.get(key)); return record?.metadata ?? null; } async has(locator) { const key = this.serializeLocator(locator); const count = await this.tx("readonly", (store) => store.count(IDBKeyRange.only(key))); return count > 0; } async delete(locator) { const key = this.serializeLocator(locator); await this.tx("readwrite", (store) => store.delete(key)); } async clear() { await this.tx("readwrite", (store) => store.clear()); } } /** * Encrypt an authorization with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Authorization} authorization the authorization to encrypt. * * @returns {string} the encrypted authorization in RFC 4648 standard Base64. */ function encryptAuthorization(publicKey, authorization) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = authorization.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Encrypt a ProvingRequest with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ProvingRequest} provingRequest the ProvingRequest to encrypt. * * @returns {string} the encrypted ProvingRequest in RFC 4648 standard Base64. */ function encryptProvingRequest(publicKey, provingRequest) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = provingRequest.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Serialize a ProvingRequest for later encryption with * `encryptSerializedProvingRequest`. * * Useful when the request may have to be encrypted more than once: the * delegated proving service's one-time keys are single-use, so a client that * needs to resend a request (e.g. after the service rejected a spent or * unknown `key_id`) can keep the serialized form and re-encrypt it for a * fresh key instead of rebuilding and re-signing the request. * * The returned buffer contains the plaintext request (including the signed * authorization and its inputs) and is owned by the caller: keep it only as * long as a resend may still be needed, then overwrite it with * `zeroizeBytes(serialized)`. Zeroization in JavaScript is best-effort (see * `zeroizeBytes`), and transient copies made inside the wasm boundary by * `toBytesLe` are outside its reach. * * @param {ProvingRequest} provingRequest the ProvingRequest to serialize. * * @returns {Uint8Array} the serialized ProvingRequest bytes. */ function serializeProvingRequest(provingRequest) { return provingRequest.toBytesLe(); } /** * Encrypt an already-serialized ProvingRequest (as produced by * `serializeProvingRequest`) with a cryptobox X25519 public key * (libsodium-compatible wire format). * * Produces exactly the same ciphertext format as `encryptProvingRequest` — * `encryptSerializedProvingRequest(pk, serializeProvingRequest(req))` and * `encryptProvingRequest(pk, req)` are interchangeable from the proving * service's point of view. * * The input buffer is not mutated and deliberately not zeroized here: the * caller keeps ownership so the same bytes can be re-encrypted for another * one-time key (retry). Call `zeroizeBytes(serializedProvingRequest)` once * no resend can be needed anymore. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Uint8Array} serializedProvingRequest the serialized ProvingRequest bytes. * * @returns {string} the encrypted ProvingRequest in RFC 4648 standard Base64. */ function encryptSerializedProvingRequest(publicKey, serializedProvingRequest) { return encryptMessage(publicKey, serializedProvingRequest); } /** * Encrypt a view key with a cryptobox X25519 public key (libsodium-compatible wire format). * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ViewKey} viewKey the view key to encrypt. * * @returns {string} the encrypted view key in RFC 4648 standard Base64. */ function encryptViewKey(publicKey, viewKey) { // Zeroize the intermediate plaintext bytes regardless of success or // failure — same pattern as encryptRegistrationRequest. const bytes = viewKey.toBytesLe(); try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(bytes); } } /** * Encrypt a record scanner registration request. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {ViewKey} viewKey the view key to encrypt. * @param {number} start the start height of the registration request. * * @returns {string} the encrypted view key in RFC 4648 standard Base64. */ function encryptRegistrationRequest(publicKey, viewKey, start) { // Turn the view key into a Uint8Array. const vk_bytes = viewKey.toBytesLe(); // Create a new array to hold the original bytes and the 4-byte start height. const bytes = new Uint8Array(vk_bytes.length + 4); // Copy existing bytes. bytes.set(vk_bytes, 0); // Write the 4-byte number in LE format at the end of the array. const view = new DataView(bytes.buffer); view.setUint32(vk_bytes.length, start, true); // Encrypt the encoded bytes, ensuring sensitive intermediate // byte arrays are zeroized regardless of success or failure. try { return encryptMessage(publicKey, bytes); } finally { zeroizeBytes(vk_bytes); zeroizeBytes(bytes); } } /** * Best-effort zeroization of a byte array by overwriting all bytes with zeros. * Use this to clear sensitive data (e.g., key bytes) from memory when working * with Uint8Array representations of keys or other secrets. * * This is best-effort in JavaScript — the JIT compiler could theoretically * elide the fill if the array is never read again (though current engines * do not). For deterministic zeroization of key material, use * `Account.destroy()` or call `.free()` on key objects (PrivateKey, ViewKey, * ComputeKey, GraphKey) whose Rust Drop implementations zeroize memory * before deallocation. * * Note: This cannot zeroize JavaScript strings, which are immutable and managed * by the garbage collector. Prefer using byte array representations of sensitive * data over strings whenever possible. * * @param {Uint8Array} bytes The byte array to zeroize * * @example * const keyBytes = privateKey.toBytesLe(); * // ... use keyBytes ... * zeroizeBytes(keyBytes); // Overwrite with zeros when done */ function zeroizeBytes(bytes) { bytes.fill(0); } /** * Encrypt arbitrary bytes with a cryptobox public key using the libsodium * `crypto_box_seal` wire format. * * The implementation is delegated to `@serenity-kit/noble-sodium`, which * composes the primitive steps of `crypto_box_seal` — ephemeral X25519 * keypair, blake2b-24 nonce derivation over `epk || rpk`, HSalsa20 key * derivation from the ECDH shared secret, and XSalsa20-Poly1305 AEAD — on * top of the audited `@noble/*` primitives. Output is byte-identical to * libsodium's `crypto_box_seal` for any given ephemeral key, so ciphertexts * are decryptable by any libsodium-compatible backend (e.g. `sodiumoxide`, * `libsodium-sys`) with no changes. * * @param {string} publicKey The cryptobox X25519 public key to encrypt with (encoded in RFC 4648 standard Base64). * @param {Uint8Array} message the bytes to encrypt. * * @returns {string} the encrypted bytes in RFC 4648 standard Base64. */ function encryptMessage(publicKey, message) { const publicKeyBytes = base.base64.decode(publicKey); const ciphertext = nobleSodium.cryptoBoxSeal({ message, publicKey: publicKeyBytes }); return base.base64.encode(ciphertext); } /** * Key Management class. Enables the creation of a new Aleo Account, importation of an existing account from * an existing private key or seed, and message signing and verification functionality. An Aleo Account is generated * from a randomly generated seed (number) from which an account private key, view key, and a public account address are * derived. The private key lies at the root of an Aleo account. It is a highly sensitive secret and should be protected * as it allows for creation of Aleo Program executions and arbitrary value transfers. The View Key allows for decryption * of a user's activity on the blockchain. The Address is the public address to which other users of Aleo can send Aleo * credits and other records to. This class should only be used in environments where the safety of the underlying key * material can be assured. * * When an Account is no longer needed, call {@link destroy} to securely zeroize and free all sensitive key material * from WASM memory. Alternatively, use `[Symbol.dispose]()` with the `using` declaration in ES2024+ environments. * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a new account * const myRandomAccount = new Account(); * * // Create an account from a randomly generated seed * const seed = new Uint8Array([94, 91, 52, 251, 240, 230, 226, 35, 117, 253, 224, 210, 175, 13, 205, 120, 155, 214, 7, 169, 66, 62, 206, 50, 188, 40, 29, 122, 40, 250, 54, 18]); * const mySeededAccount = new Account({seed: seed}); * * // Create an account from an existing private key * const myExistingAccount = new Account({privateKey: process.env.privateKey}); * * // Sign a message * const hello_world = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]); * const signature = myRandomAccount.sign(hello_world); * * // Verify a signature * assert(myRandomAccount.verify(hello_world, signature)); * * // Securely destroy the account when done * myRandomAccount.destroy(); */ class Account { _privateKey; _viewKey; _computeKey; _address; _destroyed = false; constructor(params = {}) { try { this._privateKey = this.privateKeyFromParams(params); } catch (e) { logger.error("Wrong parameter", e); throw new Error("Wrong Parameter"); } this._viewKey = testnet_js.ViewKey.from_private_key(this._privateKey); this._computeKey = testnet_js.ComputeKey.from_private_key(this._privateKey); this._address = testnet_js.Address.from_private_key(this._privateKey); } /** * Attempts to create an account from a private key ciphertext * @param {PrivateKeyCiphertext | string} ciphertext The encrypted private key ciphertext or its string representation * @param {string} password The password used to decrypt the private key ciphertext * @returns {Account} A new Account instance created from the decrypted private key * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create an account object from a previously encrypted ciphertext and password. * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); */ static fromCiphertext(ciphertext, password) { try { ciphertext = (typeof ciphertext === "string") ? testnet_js.PrivateKeyCiphertext.fromString(ciphertext) : ciphertext; const _privateKey = testnet_js.PrivateKey.fromPrivateKeyCiphertext(ciphertext, password); try { return new Account({ privateKey: _privateKey }); } finally { _privateKey.free(); // Zeroize + free the temporary; Account owns its own clone } } catch (e) { throw new Error("Wrong password or invalid ciphertext"); } } /** * Validates whether the given input is a valid Aleo address. * @param {string | Uint8Array} address The address to validate, either as a string or bytes * @returns {boolean} True if the address is valid, false otherwise * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const isValid = Account.isValidAddress("aleo1rhgdu77hgyqd3xjj8ucu3jj9r2krwz6mnzyd80gncr5fxcwlh5rsvzp9px"); * console.log(isValid); // true * * const isInvalid = Account.isValidAddress("invalid_address"); * console.log(isInvalid); // false */ static isValidAddress(address) { return testnet_js.Address.isValid(address); } /** * Creates a PrivateKey from the provided parameters. * @param {AccountParam} params The parameters containing either a private key string, PrivateKey object, or a seed * @returns {PrivateKey} A PrivateKey instance derived from the provided parameters */ privateKeyFromParams(params) { if (params.seed) { return testnet_js.PrivateKey.from_seed_unchecked(params.seed); } if (params.privateKey) { if (typeof params.privateKey === 'string') { return testnet_js.PrivateKey.from_string(params.privateKey); } // Clone the PrivateKey WASM object via byte serialization to avoid // creating an immutable JS string of the private key. const bytes = params.privateKey.toBytesLe(); try { return testnet_js.PrivateKey.fromBytesLe(bytes); } finally { zeroizeBytes(bytes); } } return new testnet_js.PrivateKey(); } /** * Throws an error if this account has been destroyed. */ assertNotDestroyed() { if (this._destroyed) { throw new Error("Account has been destroyed. Create a new Account instance."); } } /** * Returns the PrivateKey associated with the account. * @returns {PrivateKey} The private key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const privateKey = account.privateKey(); */ privateKey() { this.assertNotDestroyed(); return this._privateKey; } /** * Returns the ViewKey associated with the account. * @returns {ViewKey} The view key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const viewKey = account.viewKey(); */ viewKey() { this.assertNotDestroyed(); return this._viewKey; } /** * Returns the ComputeKey associated with the account. * @returns {ComputeKey} The compute key of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const computeKey = account.computeKey(); */ computeKey() { this.assertNotDestroyed(); return this._computeKey; } /** * Returns the Aleo address associated with the account. * @returns {Address} The public address of the account * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const address = account.address(); */ address() { this.assertNotDestroyed(); return this._address; } /** * Deep clones the Account via byte serialization of the private key, * avoiding creation of immutable JS string representations of the private key. * @returns {Account} A new Account instance with the same private key * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const clonedAccount = account.clone(); */ clone() { this.assertNotDestroyed(); return new Account({ privateKey: this._privateKey }); } /** * Returns the address of the account in a string representation. * * @returns {string} The string representation of the account address */ toString() { this.assertNotDestroyed(); return this.address().to_string(); } /** * Encrypts the account's private key with a password. * * @param {string} password Password to encrypt the private key. * @returns {PrivateKeyCiphertext} The encrypted private key ciphertext * * @example * import { Account } from "@provablehq/sdk/testnet.js"; * * const account = new Account(); * const ciphertext = account.encryptAccount("password"); * process.env.ciphertext = ciphertext.toString(); */ encryptAccount(password) { this.assertNotDestroyed(); return this._privateKey.toCiphertext(password); } /** * Decrypts an encrypted record string into a plaintext record object. * * @param {string} ciphertext A string representing the ciphertext of a record. * @returns {RecordPlaintext} The decrypted record plaintext * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Decrypt any records the account owns. * const decryptedRecords = []; * for (const record of records) { * if (account.decryptRecord(record)) { * decryptedRecords.push(record); * } * } */ decryptRecord(ciphertext) { this.assertNotDestroyed(); return this._viewKey.decrypt(ciphertext); } /** * Decrypts an array of Record ciphertext strings into an array of record plaintext objects. * * @param {string[]} ciphertexts An array of strings representing the ciphertexts of records. * @returns {RecordPlaintext[]} An array of decrypted record plaintexts * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Decrypt any records the account owns. If the account owns no records, the array will be empty. * const decryptedRecords = account.decryptRecords(records); */ decryptRecords(ciphertexts) { this.assertNotDestroyed(); return ciphertexts.map((ciphertext) => this._viewKey.decrypt(ciphertext)); } /** * Generates a record view key from the account owner's view key and the record ciphertext. * This key can be used to decrypt the record without revealing the account's view key. * @param {RecordCiphertext | string} recordCiphertext The record ciphertext to generate the view key for * @returns {Field} The record view key * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create an account object from a previously encrypted ciphertext and password. * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Generate a record view key from the account's view key and a record ciphertext * const recordCiphertext = RecordCiphertext.fromString("your_record_ciphertext_here"); * const recordViewKey = account.generateRecordViewKey(recordCiphertext); */ generateRecordViewKey(recordCiphertext) { this.assertNotDestroyed(); if (typeof recordCiphertext === 'string') { recordCiphertext = testnet_js.RecordCiphertext.fromString(recordCiphertext); } if (!(recordCiphertext.isOwner(this._viewKey))) { throw new Error("The record ciphertext does not belong to this account"); } return testnet_js.EncryptionToolkit.generateRecordViewKey(this._viewKey, recordCiphertext); } /** * Generates a transition view key from the account owner's view key and the transition public key. * This key can be used to decrypt the private inputs and outputs of a the transition without * revealing the account's view key. * @param {string | Group} tpk The transition public key * @returns {Field} The transition view key * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Generate a transition view key from the account's view key and a transition public key * const tpk = Group.fromString("your_transition_public_key_here"); * * const transitionViewKey = account.generateTransitionViewKey(tpk); */ generateTransitionViewKey(tpk) { this.assertNotDestroyed(); if (typeof tpk === 'string') { tpk = testnet_js.Group.fromString(tpk); } return testnet_js.EncryptionToolkit.generateTvk(this._viewKey, tpk); } /** * Determines whether the account owns a ciphertext record. * @param {RecordCiphertext | string} ciphertext The record ciphertext to check ownership of * @returns {boolean} True if the account owns the record, false otherwise * * @example * // Import the AleoNetworkClient and Account classes * import { AleoNetworkClient, Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const networkClient = new AleoNetworkClient("https://api.provable.com/v2"); * const account = Account.fromCiphertext(process.env.ciphertext!, process.env.password!); * * // Get the record ciphertexts from a transaction and check ownership of them. * const transaction = await networkClient.getTransactionObject("at1fjy6s9md2v4rgcn3j3q4qndtfaa2zvg58a4uha0rujvrn4cumu9qfazxdd"); * const records = transaction.records(); * * // Check if the account owns any of the record ciphertexts present in the transaction. * const ownedRecords = []; * for (const record of records) { * if (account.ownsRecordCiphertext(record)) { * ownedRecords.push(record); * } * } */ ownsRecordCiphertext(ciphertext) { this.assertNotDestroyed(); if (typeof ciphertext === 'string') { try { const ciphertextObject = testnet_js.RecordCiphertext.fromString(ciphertext); return ciphertextObject.isOwner(this._viewKey); } catch (e) { return false; } } else { return ciphertext.isOwner(this._viewKey); } } /** * Signs a message with the account's private key. * Returns a Signature. * * @param {Uint8Array} message Message to be signed. * @returns {Signature} Signature over the message in bytes. * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); * * // Create an account and a message to sign. * const account = new Account(); * const message = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]) * const signature = account.sign(message); * * // Verify the signature. * assert(account.verify(message, signature)); */ sign(message) { this.assertNotDestroyed(); return this._privateKey.sign(message); } /** * Verifies the Signature on a message. * * @param {Uint8Array} message Message in bytes to be signed. * @param {Signature} signature Signature to be verified. * @returns {boolean} True if the signature is valid, false otherwise. * * @example * // Import the Account class * import { Account } from "@provablehq/sdk/testnet.js"; * * // Create a connection to the Aleo network and an account * const account = Account.fromCiphertext(process.env.ciphertext, process.env.password); * * // Sign a message. * const message = Uint8Array.from([104, 101, 108, 108, 111 119, 111, 114, 108, 100]) * const signature = account.sign(message); * * // Verify the signature. * assert(account.verify(message, signature)); */ verify(message, signature) { this.assertNotDestroyed(); return this._address.verify(message, signature); } /** * Securely destroys the account by zeroizing and freeing all sensitive key material * from WASM memory. After calling this method, the account object should not be used. * * This triggers the Rust-level zeroizing Drop implementation which overwrites private key, * view key, and compute key bytes with zeros in WASM linear memory before deallocation. * * Note: If destroy() is never called, the FinalizationRegistry (set up by wasm-bindgen) * will eventually trigger cleanup via GC, but the timing is non-deterministic. For security- * sensitive applications, always call destroy() explicitly when the account is no longer needed. * * @example * const account = new Account(); * // ... use account ... * account.destroy(); // Securely cleans up key material */ destroy() { if (this._destroyed) return; this._destroyed = true; // Free sensitive WASM objects (triggers Rust Drop -> zeroization). // try/catch guards against double-free if FinalizationRegistry already ran. try { this._privateKey.free(); } catch (_) { /* already freed */ } try { this._viewKey.free(); } catch (_) { /* already freed */ } try { this._computeKey.free(); } catch (_) { /* already freed */ } // Address is public data but free it for completeness. try { this._address.free(); } catch (_) { /* already freed */ } } /** * Implements the Disposable interface for use with `using` declarations (ES2024+). * Calls {@link destroy} to securely clean up key material. * * @example * { * using account = new Account(); * // ... use account ... * } // account is automatically destroyed here */ [Symbol.dispose]() { this.destroy(); } } function detectBrowser() { const userAgent = navigator.userAgent; if (/chrome|crios|crmo/i.test(userAgent) && !/edge|edg|opr/i.test(userAgent)) { return "chrome"; } else if (/firefox|fxios/i.test(userAgent)) { return "firefox"; } else if (/safari/i.test(userAgent) && !/chrome|crios|crmo|android/i.test(userAgent)) { return "safari"; } else if (/edg/i.test(userAgent)) { return "edge"; } else if (/opr\//i.test(userAgent)) { return "opera"; } else { return "browser"; } } function isNode() { return (typeof process !== "undefined" && process.versions != null && process.versions.node != null); } function environment() { if (typeof process !== "undefined" && process.release?.name === "node") { return "node"; } else if (typeof window !== "undefined") { return detectBrowser(); } else { return "unknown"; } } function logAndThrow(message) { logger.error(message); throw new Error(message); } /* * Per-origin cookie jar used by `cookieAffinityTransport`. * * Some Aleo backends sit behind a gateway (e.g. Kong) that uses cookie-based * session affinity: the server sets a routing cookie on the first response and * expects it back on subsequent requests so per-session state stays on the same * upstream instance. Browsers and iOS NSURLSession persist cookies automatically, * but Node `fetch` and bare React Native do not — without a jar, those runtimes * land on a random upstream per request. * * Only cookies named in `AFFINITY_COOKIE_NAMES` are captured. The jar is * module-scoped — the same process shares routing cookies per origin — but the * whitelist keeps unrelated cookies (auth, session, CSRF) out of it, so other * SDK clients hitting the same origin can't accidentally inherit caller state. * * In a browser this jar is effectively a no-op: `Cookie` is a forbidden request * header, so the manual value set here is dropped by the browser and the * browser's own cookie store takes over. */ const AFFINITY_COOKIE_NAMES = new Set(["route"]); const cookieJar = new Map(); function isRequestLike(value) { return (typeof Request !== "undefined" && value !== null && typeof value === "object" && value instanceof Request); } function isHeadersLike(value) { return (typeof Headers !== "undefined" && value !== null && typeof value === "object" && value instanceof Headers); } function originOf(urlOrReq) { try { const raw = typeof urlOrReq === "string" ? urlOrReq : urlOrReq instanceof URL ? urlOrReq.toString() : isRequestLike(urlOrReq) ? urlOrReq.url : String(urlOrReq); const parsed = new URL(raw); return `${parsed.protocol}//${parsed.host}`; } catch { return null; } } /* * Derives the origin a response's `Set-Cookie` belongs to. `fetch` may follow * redirects across origins (or http→https), in which case the final response * headers belong to `response.url`'s origin, not the request input's. Returns * null when the response has no usable `url` (e.g. the minimal response-like * objects existing SDK tests stub fetch with), so callers can fall back to the * request-derived origin. */ function responseOriginOf(response) { if (!response || typeof response !== "object") return null; const url = response.url; if (typeof url !== "string" || url.length === 0) return null; return originOf(url); } function cookieHeaderFor(origin) { if (!origin) return null; const jar = cookieJar.get(origin); if (!jar || jar.size === 0) return null; const parts = []; for (const [name, value] of jar) parts.push(`${name}=${value}`); return parts.join("; "); } /* * Reads Set-Cookie values from a fetch-style response in a defensive way. * Existing test mocks return minimal response-like objects without a * `headers` field, and Node's fetch surfaces multiple Set-Cookie headers via * `getSetCookie()` (not `get('set-cookie')`, which returns null or a * comma-joined string depending on the implementation). */ function readSetCookies