import { describe, expect, test } from 'bun:test' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Wallet as EthersWallet } from 'ethers' import type { Address, Chain, LocalAccount, PublicClient, WalletClient } from 'viem' import { type PrivateKeyAccount, generatePrivateKey, privateKeyToAccount, toAccount, } from 'viem/accounts' import { KeystoreFileOperatorSigner } from '../operator/keystore-file' import { RawPrivkeyOperatorSigner } from '../operator/raw-privkey' import type { OperatorSigner } from '../operator/signer' import { OPERATOR_BLOB_SCOPES, OPERATOR_KEYSTORE_VERSION, decodeKeystoreBytes, decodeOperatorBlobBytes, decryptAgentKey, decryptOperatorBlob, deriveBlobKey, deriveKeystoreKey, deriveLegacyEmptyDomainKey, encodeKeystoreBytes, encodeOperatorBlobBytes, encryptAgentKey, encryptOperatorBlob, sniffKeystoreVersion, tryDecryptKeystoreWithKey, tryDecryptOperatorBlobWithKey, } from './operator-keystore-crypto' describe('operator-keystore-crypto', () => { test('encrypt + decrypt round-trip via RawPrivkeyOperatorSigner', async () => { const operatorPrivkey = generatePrivateKey() const signer = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) expect(keystore.version).toBe(OPERATOR_KEYSTORE_VERSION) expect(keystore.blob.length).toBeGreaterThan(0) const decrypted = await decryptAgentKey({ signer, agentAddress, keystore }) expect(decrypted).toBe(agentPrivkey) }) test('encrypt + decrypt round-trip via KeystoreFileOperatorSigner', async () => { const tmp = await mkdtemp(join(tmpdir(), 'anima-op-ks-')) try { const operatorPrivkey = generatePrivateKey() const ethersWallet = new EthersWallet(operatorPrivkey) const encryptedJson = await ethersWallet.encrypt('test-passphrase') const path = join(tmp, 'operator.json') await writeFile(path, encryptedJson) const signer = new KeystoreFileOperatorSigner({ path, passphrase: 'test-passphrase' }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) const decrypted = await decryptAgentKey({ signer, agentAddress, keystore }) expect(decrypted).toBe(agentPrivkey) } finally { await rm(tmp, { recursive: true, force: true }) } }, 30_000) test('different operator privkeys derive different keys (cross-decrypt fails)', async () => { const operatorA = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const operatorB = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer: operatorA, agentAddress, agentPrivkey }) await expect(decryptAgentKey({ signer: operatorB, agentAddress, keystore })).rejects.toThrow() }) test('different agent addresses derive different keys (so blobs are domain-separated)', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentA = privateKeyToAccount(generatePrivateKey()).address const agentB = privateKeyToAccount(generatePrivateKey()).address const agentPrivkey = generatePrivateKey() const keystore = await encryptAgentKey({ signer, agentAddress: agentA, agentPrivkey }) await expect(decryptAgentKey({ signer, agentAddress: agentB, keystore })).rejects.toThrow() }) test('two keystores from same operator+agent produce different ciphertexts (random IV)', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const ks1 = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) const ks2 = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) expect(ks1.blob).not.toBe(ks2.blob) expect(await decryptAgentKey({ signer, agentAddress, keystore: ks1 })).toBe(agentPrivkey) expect(await decryptAgentKey({ signer, agentAddress, keystore: ks2 })).toBe(agentPrivkey) }) test('encodeKeystoreBytes + decodeKeystoreBytes round-trip', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) const bytes = encodeKeystoreBytes(keystore) const decoded = decodeKeystoreBytes(bytes) expect(decoded.version).toBe(keystore.version) expect(decoded.blob).toBe(keystore.blob) expect(await decryptAgentKey({ signer, agentAddress, keystore: decoded })).toBe(agentPrivkey) }) test('decode rejects v1 (passphrase) blobs cleanly', () => { const v1Blob = new TextEncoder().encode(JSON.stringify({ version: 1, blob: 'x' })) expect(() => decodeKeystoreBytes(v1Blob)).toThrow(/version 1/) }) test('sniffKeystoreVersion returns version field for both v1 and v2 shapes', () => { const v1Bytes = new TextEncoder().encode(JSON.stringify({ version: 1, blob: 'aaaa' })) const v2Bytes = new TextEncoder().encode(JSON.stringify({ version: 2, blob: 'bbbb' })) const garbage = new TextEncoder().encode('not-json') expect(sniffKeystoreVersion(v1Bytes)).toBe(1) expect(sniffKeystoreVersion(v2Bytes)).toBe(2) expect(sniffKeystoreVersion(garbage)).toBeNull() }) test('signer determinism: same operator + agent produces same derived key (decrypt across calls works)', async () => { const operatorPrivkey = generatePrivateKey() const signerA = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey }) const signerB = new RawPrivkeyOperatorSigner({ privkey: operatorPrivkey }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer: signerA, agentAddress, agentPrivkey }) const decrypted = await decryptAgentKey({ signer: signerB, agentAddress, keystore }) expect(decrypted).toBe(agentPrivkey) }) // -- encryptOperatorBlob / decryptOperatorBlob (Phase 12 generalized helpers) test('operator blob: encrypt + decrypt round-trip with telegram scope', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address const plaintext = new TextEncoder().encode( JSON.stringify({ telegram: { botToken: 'abc:xyz', allowedUserIds: [123] } }), ) const blob = await encryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.TELEGRAM, agentAddress: agent, plaintext, }) expect(blob.version).toBe(OPERATOR_KEYSTORE_VERSION) expect(blob.scope).toBe(OPERATOR_BLOB_SCOPES.TELEGRAM) const decrypted = await decryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.TELEGRAM, agentAddress: agent, blob, }) expect(new TextDecoder().decode(decrypted)).toBe(new TextDecoder().decode(plaintext)) }) test('operator blob: scope mismatch refuses decrypt', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address const blob = await encryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.TELEGRAM, agentAddress: agent, plaintext: new TextEncoder().encode('payload'), }) await expect( decryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.KEYSTORE, // intentionally wrong agentAddress: agent, blob, }), ).rejects.toThrow(/scope mismatch/) }) test('operator blob: cross-scope keys are different (sig replay across scopes is impossible)', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address const tgBlob = await encryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.TELEGRAM, agentAddress: agent, plaintext: new TextEncoder().encode('tg-payload'), }) // Force a "fake" keystore-scoped blob by hand-mutating tgBlob.scope and // re-trying decrypt under KEYSTORE scope. Should fail with auth/scope // error, never silently leak. const tampered = { ...tgBlob, scope: OPERATOR_BLOB_SCOPES.KEYSTORE } await expect( decryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.KEYSTORE, agentAddress: agent, blob: tampered, }), ).rejects.toThrow() }) // -- v0.24.9 WC EIP712Domain canonical + legacy fallback /** * MockDualVariantSigner simulates the post-v0.24.9 WC Account that signs * canonically by default but exposes `signTypedDataLegacyEmptyDomain` for * backwards-compat decrypt of pre-v0.24.9 keystores. The mock backs the * two paths with two DIFFERENT operator privkeys so the resulting ECDSA * signatures (and the HKDF-derived AES keys) genuinely differ. This is * exactly the divergence the real WC + MM behavior caused: same operator * EOA, two different domain-separator hashes, two different sigs. */ class MockDualVariantSigner implements OperatorSigner { readonly source = 'mock-dual-variant' constructor( private readonly canonicalAcct: PrivateKeyAccount, private readonly legacyAcct: PrivateKeyAccount, ) {} async address(): Promise
{ return this.canonicalAcct.address } async account(): Promise { const canonical = this.canonicalAcct const legacy = this.legacyAcct const acct = toAccount({ address: canonical.address, async signMessage({ message }) { return canonical.signMessage({ message }) }, async signTransaction(tx) { return canonical.signTransaction(tx) }, // biome-ignore lint/suspicious/noExplicitAny: forwarding typed-data async signTypedData(td: any) { return canonical.signTypedData(td) }, }) Object.defineProperty(acct, 'signTypedDataLegacyEmptyDomain', { // biome-ignore lint/suspicious/noExplicitAny: escape hatch is intentionally untyped value: async (td: any) => legacy.signTypedData(td), enumerable: false, writable: false, }) return acct } async walletClient(): Promise { throw new Error('walletClient unused in test') } async publicClient(): Promise { throw new Error('publicClient unused in test') } chain(): Chain { throw new Error('chain unused in test') } } test('legacy fallback: keystore encrypted under empty-domain variant decrypts via fallback', async () => { // Simulate pre-v0.24.9: legacy WC's "canonical" was the empty-EIP712Domain // hash, so the on-disk keystore is bound to privkey L's signature. After // the v0.24.9 fix, the same operator's signTypedData now produces the // canonical hash (different sig → different AES key). The mock signer // returns canonical-acct's sig on signTypedData (which fails AES-GCM) // and legacy-acct's sig on signTypedDataLegacyEmptyDomain (which is what // the keystore was encrypted under, so the fallback succeeds). const canonicalPrivkey = generatePrivateKey() const legacyPrivkey = generatePrivateKey() const canonicalAcct = privateKeyToAccount(canonicalPrivkey) const legacyAcct = privateKeyToAccount(legacyPrivkey) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address // Encrypt the keystore with the LEGACY privkey acting as the operator // (mirrors what a pre-v0.24.9 WC init wrote to disk). const legacyOnlySigner = new RawPrivkeyOperatorSigner({ privkey: legacyPrivkey }) const keystore = await encryptAgentKey({ signer: legacyOnlySigner, agentAddress, agentPrivkey, }) // Decrypt with the dual-variant mock: canonical sig fails AES-GCM, but // the legacy fallback method returns legacy-acct's sig and succeeds. const dualSigner = new MockDualVariantSigner(canonicalAcct, legacyAcct) const decrypted = await decryptAgentKey({ signer: dualSigner, agentAddress, keystore, }) expect(decrypted).toBe(agentPrivkey) }) test('legacy fallback: post-v0.24.9 keystore (canonical) decrypts first try without invoking fallback', async () => { // After v0.24.9: same canonical privkey encrypts + decrypts. Legacy method // exists on the signer but is never invoked because canonical AES-GCM // succeeds on the first attempt. We assert this by routing the legacy // method through a privkey that would produce a totally different sig if // ever called — decrypt must still succeed cleanly. const canonicalAcct = privateKeyToAccount(generatePrivateKey()) const legacyAcct = privateKeyToAccount(generatePrivateKey()) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const dualSigner = new MockDualVariantSigner(canonicalAcct, legacyAcct) const keystore = await encryptAgentKey({ signer: dualSigner, agentAddress, agentPrivkey, }) const decrypted = await decryptAgentKey({ signer: dualSigner, agentAddress, keystore, }) expect(decrypted).toBe(agentPrivkey) }) test('legacy fallback: LocalAccount signers without the escape hatch keep canonical-only behavior', async () => { // RawPrivkeyOperatorSigner (LocalAccount) never exposes // signTypedDataLegacyEmptyDomain. A keystore encrypted under operator A's // canonical sig cannot be decrypted by operator B; the fallback is a // no-op and the original AES-GCM failure surfaces unchanged. const operatorA = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const operatorB = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer: operatorA, agentAddress, agentPrivkey }) await expect(decryptAgentKey({ signer: operatorB, agentAddress, keystore })).rejects.toThrow( /Unsupported state or unable to authenticate/, ) }) test('legacy fallback: precomputedKey path skips fallback (cached key is trusted)', async () => { // The headless gateway path caches the canonical AES key in // .operator-session and passes it as precomputedKey. If the cache is // stale or wrong, fail loud, never try to silently re-sign via the // legacy fallback. const canonicalPrivkey = generatePrivateKey() const legacyPrivkey = generatePrivateKey() const canonicalAcct = privateKeyToAccount(canonicalPrivkey) const legacyAcct = privateKeyToAccount(legacyPrivkey) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const legacyOnlySigner = new RawPrivkeyOperatorSigner({ privkey: legacyPrivkey }) const keystore = await encryptAgentKey({ signer: legacyOnlySigner, agentAddress, agentPrivkey, }) // Pass a wrong 32-byte precomputedKey. Even with a dual-variant signer // available, the fallback must NOT be attempted because the caller // explicitly opted into cached-key mode. const wrongKey = Buffer.alloc(32, 0) const dualSigner = new MockDualVariantSigner(canonicalAcct, legacyAcct) await expect( decryptAgentKey({ signer: dualSigner, agentAddress, keystore, precomputedKey: wrongKey, }), ).rejects.toThrow(/Unsupported state or unable to authenticate/) }) test('legacy fallback: operator-blob decrypt also threads the dual-path on scoped blobs', async () => { // The same EIP712Domain trap affected Phase 12 telegram blobs + the v0.23 // PROFILE slot. Verify decryptOperatorBlob applies the same fallback. const canonicalPrivkey = generatePrivateKey() const legacyPrivkey = generatePrivateKey() const canonicalAcct = privateKeyToAccount(canonicalPrivkey) const legacyAcct = privateKeyToAccount(legacyPrivkey) const agentAddress = privateKeyToAccount(generatePrivateKey()).address const legacyOnlySigner = new RawPrivkeyOperatorSigner({ privkey: legacyPrivkey }) const blob = await encryptOperatorBlob({ signer: legacyOnlySigner, scope: OPERATOR_BLOB_SCOPES.PROFILE, agentAddress, plaintext: new TextEncoder().encode('legacy-profile-payload'), }) const dualSigner = new MockDualVariantSigner(canonicalAcct, legacyAcct) const pt = await decryptOperatorBlob({ signer: dualSigner, scope: OPERATOR_BLOB_SCOPES.PROFILE, agentAddress, blob, }) expect(new TextDecoder().decode(pt)).toBe('legacy-profile-payload') }) test('operator blob: encode/decode bytes round-trip', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address const blob = await encryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.TELEGRAM, agentAddress: agent, plaintext: new TextEncoder().encode('hello'), }) const bytes = encodeOperatorBlobBytes(blob) const decoded = decodeOperatorBlobBytes(bytes) expect(decoded.version).toBe(blob.version) expect(decoded.scope).toBe(blob.scope) expect(decoded.blob).toBe(blob.blob) }) // -- v0.24.10 verify helpers + legacy derive wrapper -------------------- test('tryDecryptKeystoreWithKey: true with matching key, false with wrong key', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) const rightKey = await deriveKeystoreKey(signer, agentAddress) const wrongKey = Buffer.alloc(32, 0xab) expect(tryDecryptKeystoreWithKey(keystore, rightKey)).toBe(true) expect(tryDecryptKeystoreWithKey(keystore, wrongKey)).toBe(false) }) test('tryDecryptKeystoreWithKey: rejects key with wrong length without throwing', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agentPrivkey = generatePrivateKey() const agentAddress = privateKeyToAccount(agentPrivkey).address const keystore = await encryptAgentKey({ signer, agentAddress, agentPrivkey }) expect(tryDecryptKeystoreWithKey(keystore, Buffer.alloc(16, 0))).toBe(false) expect(tryDecryptKeystoreWithKey(keystore, Buffer.alloc(64, 0))).toBe(false) }) test('tryDecryptOperatorBlobWithKey: scope mismatch returns false even with correct key', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address const blob = await encryptOperatorBlob({ signer, scope: OPERATOR_BLOB_SCOPES.PROFILE, agentAddress: agent, plaintext: new TextEncoder().encode('hi'), }) const profileKey = await deriveBlobKey(signer, agent, OPERATOR_BLOB_SCOPES.PROFILE) expect(tryDecryptOperatorBlobWithKey(blob, profileKey, OPERATOR_BLOB_SCOPES.PROFILE)).toBe(true) // Wrong scope label refuses even though the AES key is otherwise correct. expect(tryDecryptOperatorBlobWithKey(blob, profileKey, OPERATOR_BLOB_SCOPES.TELEGRAM)).toBe( false, ) }) test('deriveLegacyEmptyDomainKey: returns null when signer has no legacy escape', async () => { const signer = new RawPrivkeyOperatorSigner({ privkey: generatePrivateKey() }) const agent = privateKeyToAccount(generatePrivateKey()).address expect(await deriveLegacyEmptyDomainKey(signer, agent, 'keystore')).toBeNull() expect(await deriveLegacyEmptyDomainKey(signer, agent, OPERATOR_BLOB_SCOPES.PROFILE)).toBeNull() }) test('deriveLegacyEmptyDomainKey: returns the legacy-variant key for dual signer (matches direct derive)', async () => { const canonicalAcct = privateKeyToAccount(generatePrivateKey()) const legacyPrivkey = generatePrivateKey() const legacyAcct = privateKeyToAccount(legacyPrivkey) const agent = privateKeyToAccount(generatePrivateKey()).address const dualSigner = new MockDualVariantSigner(canonicalAcct, legacyAcct) // The legacy-variant key derived via the escape hatch should equal the // key derived directly from the legacy privkey acting as a normal signer // (same EIP-712 message, same HKDF info, same RFC-6979 deterministic sig). const legacyDirectSigner = new RawPrivkeyOperatorSigner({ privkey: legacyPrivkey }) const legacyViaEscape = await deriveLegacyEmptyDomainKey(dualSigner, agent, 'keystore') const legacyDirect = await deriveKeystoreKey(legacyDirectSigner, agent) expect(legacyViaEscape).not.toBeNull() expect(legacyViaEscape!.equals(legacyDirect)).toBe(true) // Same for PROFILE scope. const legacyProfileViaEscape = await deriveLegacyEmptyDomainKey( dualSigner, agent, OPERATOR_BLOB_SCOPES.PROFILE, ) const legacyProfileDirect = await deriveBlobKey( legacyDirectSigner, agent, OPERATOR_BLOB_SCOPES.PROFILE, ) expect(legacyProfileViaEscape).not.toBeNull() expect(legacyProfileViaEscape!.equals(legacyProfileDirect)).toBe(true) }) })