diff --git a/contracts/src/crypto/ElGamal.compact b/contracts/src/crypto/ElGamal.compact index 6bfd1da1..dc6f7c93 100644 --- a/contracts/src/crypto/ElGamal.compact +++ b/contracts/src/crypto/ElGamal.compact @@ -34,6 +34,13 @@ pragma language_version >= 0.23.0; * scalar. Feeding a raw `persistentHash` output into `ecMulGenerator` would * occasionally exceed the Jubjub scalar field order and fault at runtime. * + * @dev Domain separation. `persistentHash>>` is NOT + * domain-separating on its own. Two circuits hashing the same arity and + * element type share one hash domain, and the hash cannot tell which slot was + * "meant" as a tag. `secretToScalar` therefore prefixes a fixed constant, and + * that constant is placed FIRST so no call to `expandRandomness` (whose two + * slots are both caller-supplied) can reproduce the tuple by ordinary use. + * * @dev TRUST ASSUMPTION — subgroup membership. This is the module's most * load-bearing assumption. Every `JubjubPoint` reaching a curve operation is * assumed to be in the Jubjub prime-order subgroup, including points supplied by @@ -102,11 +109,34 @@ module ElGamal { * @description Maps a 32-byte secret to a valid Jubjub scalar. See the * module-level hash-to-scalar note for why `degradeToTransient` is required. * + * @dev Domain separation here is load-bearing, not cosmetic. This output is + * the private key protecting every ciphertext held under an account. Public + * account identifiers derived from `Utils.computeAccountId`, for example, + * are computed as `persistentHash([secretKey])`, and that identifier + * is stored in the clear as a ledger map key. Untagged, this circuit + * would compute that same hash, so a secret used in both roles would + * have its encryption key recoverable from a published identifier by applying + * `degradeToTransient`. The tag makes the two derivations unrelated, so a + * wallet MAY safely derive its account secret and its encryption secret from + * common key material. + * + * @dev The tag is FIRST, deliberately. `expandRandomness` hashes + * `[seed, tag]` with both slots caller-supplied, so tagging this circuit as + * `[secret, tag]` would make `secretToScalar(x)` identical to + * `expandRandomness(x, )` reachable by passing this domain string + * to the parameter that exists to receive domain strings. Leading with the + * tag means reproducing this output through `expandRandomness` would require + * passing the domain constant as the *seed*. Do not normalise the orderings. + * * @param secret - The 32-byte secret to map. * @return A Field guaranteed to be a valid Jubjub scalar. */ export pure circuit secretToScalar(secret: Bytes<32>): Field { - return degradeToTransient(persistentHash>>([secret])); + return degradeToTransient( + persistentHash>>( + [pad(32, "ElGamal:secretToScalar"), secret] + ) + ); } /** diff --git a/contracts/src/crypto/test/ElGamal.test.ts b/contracts/src/crypto/test/ElGamal.test.ts index c0f48456..3d69e5a6 100644 --- a/contracts/src/crypto/test/ElGamal.test.ts +++ b/contracts/src/crypto/test/ElGamal.test.ts @@ -1,4 +1,10 @@ -import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + CompactTypeBytes, + CompactTypeVector, + convertBytesToField, + type JubjubPoint, + persistentHash, +} from '@midnight-ntwrk/compact-runtime'; import { beforeAll, describe, expect, it } from 'vitest'; import { type Ciphertext, @@ -23,6 +29,9 @@ const b32 = (label: string): Uint8Array => { const EK_A = b32('elgamal-ek-A'); const EK_B = b32('elgamal-ek-B'); +// Mirrors the `pad(32, ...)` tag `secretToScalar` prefixes its input with. +const SECRET_TO_SCALAR_TAG = b32('ElGamal:secretToScalar'); + // Explicit encryption randomness. Any value below the Jubjub scalar field // order (~2^252) is a valid scalar; these small constants keep the tests // deterministic and let us assert that distinct randomness yields distinct @@ -61,6 +70,37 @@ describe('ElGamal', () => { it('returns a positive scalar', async () => { expect(await contract.secretToScalar(EK_A)).toBeGreaterThan(0n); }); + + // Guards the domain separation on `secretToScalar` (rationale in its @dev + // notes). Account identifiers are the same hash, untagged, and public. + it('is not recoverable from a published account identifier', async () => { + // What any observer can do: take the identifier and apply the circuit's + // own truncation. + const accountId = persistentHash( + new CompactTypeVector(1, new CompactTypeBytes(32)), + [EK_A], + ); + + expect(await contract.secretToScalar(EK_A)).not.toBe( + convertBytesToField(31, accountId, 'attacker'), + ); + }); + + // Both pin the tag's POSITION, and flip if the order becomes `[secret, tag]` + it('is not reproducible by passing the domain tag as expandRandomness tag', async () => { + expect( + await contract.expandRandomness(EK_A, SECRET_TO_SCALAR_TAG), + ).not.toBe(await contract.secretToScalar(EK_A)); + }); + + // An accepted residual, not a guarantee: `expandRandomness` is itself + // untagged, so its seed slot can carry this tag. Removable by tagging + // `expandRandomness` (arity 3, tag first) + it('can be reproduced via expandRandomness with the tag in the seed slot', async () => { + expect(await contract.expandRandomness(SECRET_TO_SCALAR_TAG, EK_A)).toBe( + await contract.secretToScalar(EK_A), + ); + }); }); // ------------------------------------------------------------------------- diff --git a/contracts/src/token/ConfidentialFungibleToken.compact b/contracts/src/token/ConfidentialFungibleToken.compact index b16215c2..bda67735 100644 --- a/contracts/src/token/ConfidentialFungibleToken.compact +++ b/contracts/src/token/ConfidentialFungibleToken.compact @@ -11,7 +11,17 @@ pragma language_version >= 0.23.0; * follows the same pattern as `FungibleToken`: a witness-derived * `accountId = persistentHash(secretKey)`. Encryption uses a separate witness * `wit_ConfidentialTokenEK` to derive an ElGamal keypair `(ek, pk)` via - * `ElGamal_derivePk`, where `pk = g^degradeToTransient(persistentHash(EK))`. + * `ElGamal_derivePk`, where + * `pk = g^degradeToTransient(persistentHash([ElGamal domain tag, EK]))`. + * + * @notice Sharing key material between the two witnesses is PERMITTED. The + * account identifier is an untagged hash of the SK and is published as a ledger + * map key; the encryption scalar is a DOMAIN-SEPARATED hash of the EK. Because + * the two derivations are tagged differently, a wallet may return the same + * value from `wit_ConfidentialTokenSK` and `wit_ConfidentialTokenEK`, or derive + * both from one master secret, without the published identifier revealing the + * encryption key. The domain separation is what makes that safe. Do not remove + * it. See `ElGamal.secretToScalar`. * * @notice Supply. The mint/burn building blocks (`_mint`/`_burn`/`_burnFrom`) * live here; total-supply tracking is an optional add-on @@ -266,6 +276,7 @@ module ConfidentialFungibleToken { import CompactStandardLibrary; import "../crypto/ElGamal" prefix ElGamal_; import "../crypto/EcdhMask" prefix EcdhMask_; + import "../utils/Utils" prefix Utils_; // --------------------------------------------------------------------------- // Types @@ -575,7 +586,7 @@ module ConfidentialFungibleToken { * `wit_ConfidentialTokenEK` and initializes their balance to an encryption of * zero. Registration is a prerequisite for sending or receiving. * - * @circuitInfo k=13, rows=6203 + * @circuitInfo k=13, rows=8100 * * @notice Returns the caller's `accountId`. A composing contract can gate on * the returned value (e.g. assert it is KYC-approved) to restrict who may @@ -805,7 +816,7 @@ module ConfidentialFungibleToken { * `extensions/ConfidentialFungibleTokenPublicSupply`) if the deployment tracks * `totalSupply`. * - * @circuitInfo k=14, rows=14823 + * @circuitInfo k=15, rows=16720 * * Requirements: * @@ -829,7 +840,7 @@ module ConfidentialFungibleToken { * credits `to`, and pushes an encrypted memo to `to`. The amount is hidden; * the (sender, recipient) pair is public. * - * @circuitInfo k=16, rows=41804 + * @circuitInfo k=16, rows=43701 * * @notice Returns the caller's (sender's) `accountId` for caller-side gating. * The sender is derived from the same authentication `_debit` performs, so the @@ -867,7 +878,7 @@ module ConfidentialFungibleToken { * operations (`mint`/`burn`) live in a separate layer outside this conserving * surface, and only they may break conservation. * - * @circuitInfo k=16, rows=41793 + * @circuitInfo k=16, rows=43690 * * @notice Like `transfer`, the credit lands in the recipient's pending pool * (see the dual-balance note); the caller sweeps their own incoming value. @@ -909,7 +920,7 @@ module ConfidentialFungibleToken { * to change, which is what would otherwise invalidate the victim's in-flight * spend proofs (a liveness grief). * - * @circuitInfo k=13, rows=5688 + * @circuitInfo k=13, rows=7585 * * @notice Purely homomorphic: adds the two ciphertexts (same key) and resets * pending to Enc(0). No plaintext is needed; the wallet already learned the @@ -942,7 +953,7 @@ module ConfidentialFungibleToken { * refunds any prior escrow, so allowances replace rather than stack. The cap * is not public. * - * @circuitInfo k=16, rows=43217 + * @circuitInfo k=16, rows=45114 * * @notice Returns the caller's (approver's) `accountId` for caller-side gating. * @@ -1026,7 +1037,7 @@ module ConfidentialFungibleToken { * caller via `approve`) and credits it to `to`, reducing both escrow copies by * `value`. The amount is hidden. * - * @circuitInfo k=17, rows=70449 + * ???? @circuitInfo k=16, rows=72346 * * @notice Returns the caller's (spender's) `accountId` for caller-side gating. * @@ -1073,7 +1084,7 @@ module ConfidentialFungibleToken { * therefore reads its memo list and its epoch together, folds the memos into * its cache, and passes that epoch back here. * - * @circuitInfo k=13, rows=5156 + * @circuitInfo k=13, rows=7053 * * @warning Clearing memos is destructive and can permanently lock funds. The * on-chain balance ciphertext is not directly decryptable (discrete-log @@ -1163,11 +1174,15 @@ module ConfidentialFungibleToken { * `persistentHash(sk)`. Pure, so a wallet can compute its own account * identifier off-chain (no proof) before transacting. * + * @dev Delegates to `Utils.computeAccountId` rather than reimplementing the + * hash. The identifier is deliberately GLOBAL. The same key material yields + * the same id in every module that derives it. + * * @param {Bytes<32>} sk - The account secret key. * @return {Bytes<32>} - The derived accountId. */ export pure circuit computeAccountId(sk: Bytes<32>): Bytes<32> { - return persistentHash>>([sk]); + return Utils_computeAccountId(sk); } /** @@ -1271,7 +1286,7 @@ module ConfidentialFungibleToken { * recipient credit. The building block for a composing contract's * `burnFrom`; it performs no supply accounting (see `_burn`). * - * @circuitInfo k=16, rows=43479 + * ???? @circuitInfo k=16, rows=45376 * * Requirements: * diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 6e97ceb8..7ea52fc7 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -29,20 +29,29 @@ const padTag = (s: string): Uint8Array => { // Helpers // --------------------------------------------------------------------------- +/** The domain-separation tag `ElGamal.secretToScalar` prefixes its input with. */ +const SECRET_TO_SCALAR_TAG = padTag('ElGamal:secretToScalar'); + /** * @description Derives the expected pk for a given EK, mirroring the * in-circuit `_derivePk`: - * pk = ecMulGenerator(degradeToTransient(persistentHash([ek]))) + * pk = ecMulGenerator(degradeToTransient(persistentHash([TAG, ek]))) * * The `convertBytesToField` call mirrors `degradeToTransient`, producing the * field element that `ecMulGenerator` expects. * + * @note The tag is what keeps this scalar unrelated to the account identifier, + * which is `persistentHash([sk])` (untagged, and public as a ledger map key). + * Without it, a secret used in both roles would have its encryption key + * recoverable from the published identifier. See `buildAccountIdHash` below — + * that one is deliberately untagged and must stay so. + * * @note The field-element derivation from EK uses 31 bytes of the hash output * (empirically determined); the effective collision resistance is therefore 248 bits. */ const derivePk = (ek: Uint8Array) => { - const rt_type = new CompactTypeVector(1, new CompactTypeBytes(32)); - const ekHash = persistentHash(rt_type, [ek]); + const rt_type = new CompactTypeVector(2, new CompactTypeBytes(32)); + const ekHash = persistentHash(rt_type, [SECRET_TO_SCALAR_TAG, ek]); const ekField = convertBytesToField(31, ekHash, 'derivePk'); return ecMulGenerator(ekField); }; @@ -156,6 +165,22 @@ describe.skipIf(isLiveBackend())( expect(storedPk).toEqual(expectedPk); }); + it('keeps the encryption scalar unrelated to the public accountId when SK and EK are the same secret', async () => { + await cft.privateState.switchIdentity(ALICE.secretKey, ALICE.secretKey); + await cft.register(); + + const ledger = await cft.getPublicState(); + const storedPk = ledger.CFT__encryptionKeys.lookup(ALICE.accountId); + + // What any observer can derive from the published accountId. + expect(storedPk).not.toEqual( + ecMulGenerator( + convertBytesToField(31, ALICE.accountId, 'publicAccountId'), + ), + ); + expect(storedPk).toEqual(derivePk(ALICE.secretKey)); + }); + it('should store distinct pks for distinct EKs', async () => { await cft.privateState.switchIdentity( ALICE.secretKey, @@ -281,6 +306,62 @@ describe.skipIf(isLiveBackend())( // building blocks, so this suite never touches the composed mint/burn/totalSupply. // --------------------------------------------------------------------------- +describe.skipIf(isLiveBackend())( + 'ConfidentialFungibleToken: shared SK/EK through the value path', + () => { + beforeEach(async () => { + cft = await ConfidentialFungibleTokenSimulator.create( + NAME, + SYMBOL, + DECIMALS, + ); + }); + + // Every party uses ONE secret for both witnesses. + const shared = (u: typeof ALICE) => + cft.privateState.switchIdentity(u.secretKey, u.secretKey); + + const decryptsTo = (ct: any, u: typeof ALICE, value: bigint) => + elgamal.assertDecryptsTo( + ct, + elgamal.derivePk(u.secretKey), + u.secretKey, + value, + ); + + it('mints, transfers and sweeps with one secret in both roles', async () => { + for (const u of [ALICE, BOB]) { + await shared(u); + await cft.register(); + } + + await shared(ALICE); + await cft._mint(ALICE.accountId, 100n); + await cft.sweep(); + await cft.privateState.cachePlaintext( + await cft.balanceOf(ALICE.accountId), + 100n, + ); + + // `_debit` re-derives Alice's pk from the shared secret and asserts her + // balance decrypts to the claimed 100. + await cft.transfer(BOB.accountId, 40n); + + await shared(BOB); + await cft.sweep(); + + const aliceBalance = await cft.balanceOf(ALICE.accountId); + const bobBalance = await cft.balanceOf(BOB.accountId); + + expect(() => decryptsTo(aliceBalance, ALICE, 60n)).not.toThrow(); + expect(() => decryptsTo(bobBalance, BOB, 40n)).not.toThrow(); + + // Confirm the binding is real + expect(() => decryptsTo(aliceBalance, ALICE, 61n)).toThrow(); + }); + }, +); + describe.skipIf(isLiveBackend())('ConfidentialFungibleToken: transfer', () => { beforeEach(async () => { cft = await ConfidentialFungibleTokenSimulator.create( diff --git a/contracts/src/utils/Utils.compact b/contracts/src/utils/Utils.compact index 3269c182..53fabdb6 100644 --- a/contracts/src/utils/Utils.compact +++ b/contracts/src/utils/Utils.compact @@ -150,6 +150,16 @@ module Utils { * ## ID Derivation * `accountId = persistentHash(secretKey)` * + * @dev The absence of a domain-separation tag is DELIBERATE. This identifier + * is global by design: the same key material yields the same identity in + * every module deriving one, so a user who wishes to carry one identity + * across modules can. Modules wanting a per-deployment, unlinkable identity + * should not use this circuit. + * + * @dev NOT FOR PRIVATE DERIVATION. This returns an identifier, not a secret, + * and the construction is untagged. A circuit deriving a PRIVATE value from a + * secret MUST use its own domain-separation tag. + * * @param {Bytes<32>} secretKey - A 32-byte cryptographically secure random value. * * @returns {Bytes<32>} accountId - The computed account identifier.