Skip to content

Commit e101890

Browse files
authored
feat: merge-train/fairies-v5 (#24505)
BEGIN_COMMIT_OVERRIDE fix(pxe): return no shared secrets when scope keys are not held (#24487) END_COMMIT_OVERRIDE
2 parents 3ea5dee + 6f5e8b9 commit e101890

11 files changed

Lines changed: 177 additions & 80 deletions

File tree

noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,26 @@ mod test {
233233
});
234234
}
235235

236+
#[test]
237+
unconstrained fn get_handshake_secrets_returns_empty_when_scope_keys_not_held() {
238+
let env = TestEnvironment::new();
239+
let scope = AztecAddress::from_field(1);
240+
let contract_address = AztecAddress { inner: 0xdeadbeef };
241+
242+
env.utility_context_at(contract_address, |_| {
243+
let mut items = BoundedVec::new();
244+
items.push(DiscoveredHandshake { eph_pk: point_from_x_coord(1).unwrap() });
245+
let page = HandshakePage { items, total_count: 1 };
246+
let _ = OracleMock::mock("aztec_utl_callUtilityFunction").returns(page.serialize());
247+
248+
let empty: [Field; 0] = [];
249+
mock_get_shared_secrets(empty);
250+
251+
let secrets = get_handshake_secrets(contract_address, scope);
252+
assert_eq(secrets.len(), 0);
253+
});
254+
}
255+
236256
unconstrained fn mock_get_shared_secrets<let N: u32>(response_values: [Field; N]) {
237257
let response_slot: Field = 99;
238258
let response_array: EphemeralArray<Field> = EphemeralArray::at(response_slot);

noir-projects/aztec-nr/aztec/src/messages/encryption/aes128.nr

Lines changed: 47 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -376,51 +376,53 @@ impl MessageEncryption for AES128 {
376376
.and_then(|(eph_pk_x, masked_fields)| {
377377
// With the x-coordinate of the ephemeral public key we can reconstruct the point as we know that the
378378
// y-coordinate must be positive. This may fail however, as not all x-coordinates are on the curve. In
379-
// that case, we simply return `Option::none`.
380-
point_from_x_coord_and_sign(eph_pk_x, true).and_then(|eph_pk| {
381-
let s_app = get_shared_secret(recipient, eph_pk, contract_address);
382-
383-
let unmasked_fields = masked_fields.mapi(|i, field| {
384-
let unmasked = unmask_field(s_app, i, field);
385-
// If we failed to unmask the field, we are dealing with the random padding. We'll ignore it
386-
// later, so we can simply set it to 0
387-
unmasked.unwrap_or(0)
388-
});
389-
let ciphertext_without_eph_pk_x = decode_bytes_from_fields(unmasked_fields);
390-
391-
// Derive symmetric keys:
392-
let pairs = derive_aes_symmetric_key_and_iv_from_shared_secret::<2>(s_app);
393-
let (body_sym_key, body_iv) = pairs[0];
394-
let (header_sym_key, header_iv) = pairs[1];
395-
396-
// Extract the header ciphertext
397-
let header_start = 0;
398-
let header_ciphertext: [u8; HEADER_CIPHERTEXT_SIZE_IN_BYTES] =
399-
array::subarray(ciphertext_without_eph_pk_x.storage(), header_start);
400-
// We need to convert the array to a BoundedVec because the oracle expects a BoundedVec as it's
401-
// designed to work with messages with unknown length at compile time. This would not be necessary
402-
// here as the header ciphertext length is fixed. But we do it anyway to not have to have duplicate
403-
// oracles.
404-
let header_ciphertext_bvec =
405-
BoundedVec::<u8, HEADER_CIPHERTEXT_SIZE_IN_BYTES>::from_array(header_ciphertext);
406-
407-
try_aes128_decrypt(header_ciphertext_bvec, header_iv, header_sym_key)
408-
// Extract ciphertext length from header (2 bytes, big-endian)
409-
.and_then(|header_plaintext| extract_ciphertext_length(header_plaintext))
410-
.filter(|ciphertext_length| ciphertext_length <= MESSAGE_PLAINTEXT_SIZE_IN_BYTES)
411-
.map(|ciphertext_length| {
412-
// Extract and decrypt main ciphertext
413-
let ciphertext_start = header_start + HEADER_CIPHERTEXT_SIZE_IN_BYTES;
414-
let ciphertext_with_padding: [u8; MESSAGE_PLAINTEXT_SIZE_IN_BYTES] =
415-
array::subarray(ciphertext_without_eph_pk_x.storage(), ciphertext_start);
416-
BoundedVec::from_parts(ciphertext_with_padding, ciphertext_length)
417-
})
418-
// Decrypt main ciphertext and return it
419-
.and_then(|ciphertext| try_aes128_decrypt(ciphertext, body_iv, body_sym_key))
420-
// Convert bytes back to fields (32 bytes per field). Returns None if the actual bytes are
421-
// not valid.
422-
.and_then(|plaintext_bytes| try_decode_fields_from_bytes(plaintext_bytes))
423-
})
379+
// that case, we simply return `Option::none`. Secret derivation may also fail when the PXE does not
380+
// hold the recipient's keys (e.g. when syncing the scope of an address we don't control), in which
381+
// case the message is undecryptable and equally skipped.
382+
point_from_x_coord_and_sign(eph_pk_x, true)
383+
.and_then(|eph_pk| get_shared_secret(recipient, eph_pk, contract_address))
384+
.and_then(|s_app| {
385+
let unmasked_fields = masked_fields.mapi(|i, field| {
386+
let unmasked = unmask_field(s_app, i, field);
387+
// If we failed to unmask the field, we are dealing with the random padding. We'll ignore it
388+
// later, so we can simply set it to 0
389+
unmasked.unwrap_or(0)
390+
});
391+
let ciphertext_without_eph_pk_x = decode_bytes_from_fields(unmasked_fields);
392+
393+
// Derive symmetric keys:
394+
let pairs = derive_aes_symmetric_key_and_iv_from_shared_secret::<2>(s_app);
395+
let (body_sym_key, body_iv) = pairs[0];
396+
let (header_sym_key, header_iv) = pairs[1];
397+
398+
// Extract the header ciphertext
399+
let header_start = 0;
400+
let header_ciphertext: [u8; HEADER_CIPHERTEXT_SIZE_IN_BYTES] =
401+
array::subarray(ciphertext_without_eph_pk_x.storage(), header_start);
402+
// We need to convert the array to a BoundedVec because the oracle expects a BoundedVec as it's
403+
// designed to work with messages with unknown length at compile time. This would not be
404+
// necessary here as the header ciphertext length is fixed. But we do it anyway to not have to
405+
// have duplicate oracles.
406+
let header_ciphertext_bvec =
407+
BoundedVec::<u8, HEADER_CIPHERTEXT_SIZE_IN_BYTES>::from_array(header_ciphertext);
408+
409+
try_aes128_decrypt(header_ciphertext_bvec, header_iv, header_sym_key)
410+
// Extract ciphertext length from header (2 bytes, big-endian)
411+
.and_then(|header_plaintext| extract_ciphertext_length(header_plaintext))
412+
.filter(|ciphertext_length| ciphertext_length <= MESSAGE_PLAINTEXT_SIZE_IN_BYTES)
413+
.map(|ciphertext_length| {
414+
// Extract and decrypt main ciphertext
415+
let ciphertext_start = header_start + HEADER_CIPHERTEXT_SIZE_IN_BYTES;
416+
let ciphertext_with_padding: [u8; MESSAGE_PLAINTEXT_SIZE_IN_BYTES] =
417+
array::subarray(ciphertext_without_eph_pk_x.storage(), ciphertext_start);
418+
BoundedVec::from_parts(ciphertext_with_padding, ciphertext_length)
419+
})
420+
// Decrypt main ciphertext and return it
421+
.and_then(|ciphertext| try_aes128_decrypt(ciphertext, body_iv, body_sym_key))
422+
// Convert bytes back to fields (32 bytes per field). Returns None if the actual bytes are
423+
// not valid.
424+
.and_then(|plaintext_bytes| try_decode_fields_from_bytes(plaintext_bytes))
425+
})
424426
})
425427
}
426428
}

noir-projects/aztec-nr/aztec/src/oracle/shared_secret.nr

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ unconstrained fn get_shared_secrets_oracle(
1111
) -> EphemeralArray<Field> {}
1212

1313
/// Convenience wrapper around [`get_shared_secrets`] for a single ephemeral public key.
14+
///
15+
/// Returns `none` when the PXE does not hold `address`'s keys and therefore cannot derive the secret.
1416
pub unconstrained fn get_shared_secret(
1517
address: AztecAddress,
1618
eph_pk: EmbeddedCurvePoint,
1719
contract_address: AztecAddress,
18-
) -> Field {
20+
) -> Option<Field> {
1921
let eph_pks: EphemeralArray<EmbeddedCurvePoint> = EphemeralArray::empty_at(GET_SHARED_SECRET_SINGLE_SLOT);
2022
eph_pks.push(eph_pk);
2123
let secrets = get_shared_secrets(address, eph_pks, contract_address);
22-
secrets.get(0)
24+
if secrets.len() == 1 {
25+
Option::some(secrets.get(0))
26+
} else {
27+
Option::none()
28+
}
2329
}
2430

2531
/// Returns app-siloed shared secrets between `address` and someone who knows the secret keys behind the given
@@ -41,19 +47,26 @@ pub unconstrained fn get_shared_secret(
4147
///
4248
/// Callers derive indexed subkeys from `s_app` via
4349
/// [`derive_shared_secret_subkey`](crate::keys::ecdh_shared_secret::derive_shared_secret_subkey).
50+
///
51+
/// Returns an empty array when the PXE does not hold `address`'s keys and therefore cannot derive any secrets. This
52+
/// happens when syncing the scope of an address the PXE does not control, and is an expected outcome rather than an
53+
/// error. Any other response length mismatch is a malformed oracle response.
4454
pub unconstrained fn get_shared_secrets(
4555
address: AztecAddress,
4656
eph_pks: EphemeralArray<EmbeddedCurvePoint>,
4757
contract_address: AztecAddress,
4858
) -> EphemeralArray<Field> {
4959
let response = get_shared_secrets_oracle(address, eph_pks, contract_address);
50-
assert(response.len() == eph_pks.len(), "get_shared_secrets: response length does not match request length");
60+
assert(
61+
(response.len() == eph_pks.len()) | (response.len() == 0),
62+
"get_shared_secrets: response length does not match request length",
63+
);
5164
response
5265
}
5366

5467
mod test {
5568
use crate::ephemeral::EphemeralArray;
56-
use crate::oracle::shared_secret::get_shared_secrets;
69+
use crate::oracle::shared_secret::{get_shared_secret, get_shared_secrets};
5770
use crate::protocol::{address::AztecAddress, traits::FromField};
5871
use crate::test::helpers::test_environment::TestEnvironment;
5972
use crate::utils::point::point_from_x_coord;
@@ -110,6 +123,54 @@ mod test {
110123
});
111124
}
112125

126+
#[test]
127+
unconstrained fn returns_empty_when_pxe_does_not_hold_keys() {
128+
let env = TestEnvironment::new();
129+
env.utility_context(|_| {
130+
let empty: [Field; 0] = [];
131+
let _ = mock_get_shared_secrets(empty);
132+
133+
let pk = point_from_x_coord(1).unwrap();
134+
let eph_pks: EphemeralArray<_> = EphemeralArray::empty_at(200);
135+
eph_pks.push(pk);
136+
137+
let results: EphemeralArray<Field> = get_shared_secrets(
138+
AztecAddress::from_field(1),
139+
eph_pks,
140+
AztecAddress::from_field(2),
141+
);
142+
143+
assert_eq(results.len(), 0);
144+
});
145+
}
146+
147+
#[test]
148+
unconstrained fn single_secret_returns_the_secret() {
149+
let env = TestEnvironment::new();
150+
env.utility_context(|_| {
151+
let _ = mock_get_shared_secrets([111]);
152+
153+
let pk = point_from_x_coord(1).unwrap();
154+
let secret = get_shared_secret(AztecAddress::from_field(1), pk, AztecAddress::from_field(2));
155+
156+
assert_eq(secret.unwrap(), 111);
157+
});
158+
}
159+
160+
#[test]
161+
unconstrained fn single_secret_returns_none_when_pxe_does_not_hold_keys() {
162+
let env = TestEnvironment::new();
163+
env.utility_context(|_| {
164+
let empty: [Field; 0] = [];
165+
let _ = mock_get_shared_secrets(empty);
166+
167+
let pk = point_from_x_coord(1).unwrap();
168+
let secret = get_shared_secret(AztecAddress::from_field(1), pk, AztecAddress::from_field(2));
169+
170+
assert(secret.is_none());
171+
});
172+
}
173+
113174
#[test(should_fail_with = "response length does not match request length")]
114175
unconstrained fn cannot_return_mismatched_length() {
115176
let env = TestEnvironment::new();

noir-projects/aztec-nr/aztec/src/standard_addresses.nr

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22
use protocol_types::{address::AztecAddress, traits::FromField};
33

44
pub global STANDARD_AUTH_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field(
5-
0x0e7d3c56a185c40e4ce459eee03075b7dee2e9dc8f860157063afb3fde5ce097,
5+
0x0f53c9d679c6aa851b46bfc07e14bd070d5948b6ad1a4f7c54909f61dceb146a,
66
);
77

88
pub global STANDARD_MULTI_CALL_ENTRYPOINT_ADDRESS: AztecAddress = AztecAddress::from_field(
9-
0x03264f902d92f27dd0719cd6f5cc9c9d03ec6f5a48482e2158ebc2886e997210,
9+
0x251d9cb1aa645672268b9b9082dedba22986ba9068d5ad8ecfae53f24e16c109,
1010
);
1111

1212
pub global STANDARD_PUBLIC_CHECKS_ADDRESS: AztecAddress = AztecAddress::from_field(
13-
0x05b4a7bf960bac46cc0c22aa6ee5b663a928787c9e7410fcf99e78182f634c0b,
13+
0x2ebeb911fc3963e7d1869af2dc7fee318d1edbc957edb46a0e99b46867ba4492,
1414
);
1515

1616
pub global STANDARD_HANDSHAKE_REGISTRY_ADDRESS: AztecAddress = AztecAddress::from_field(
17-
0x26f11691c7efea98db7eb7b4460cbc3f75db19772db2c2dbb8978979bcc5e388,
17+
0x1523a141a33e5aa8d72624823b8592b87799c9ee0c086dd9f3c80661905366ab,
1818
);

noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ unconstrained fn non_interactive_handshake_is_discovered_by_recipient() {
466466
// The recipient derives the same app-siloed secret from the discovered eph_pk via ECDH.
467467
let handshake = discovered.items.get(0);
468468
let recipient_secret = env.utility_context_at(sender, |_| get_shared_secret(recipient, handshake.eph_pk, sender));
469-
assert_eq(recipient_secret, returned_secrets.shared);
469+
assert_eq(recipient_secret.unwrap(), returned_secrets.shared);
470470

471471
// The custom sync hook falls through to `do_sync_state`, so default note discovery still works.
472472
let utility_secrets = env
3.69 KB
Binary file not shown.

yarn-project/end-to-end/src/automine/accounts/2_pxes.parallel.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,7 @@ describe('automine/accounts/2_pxes', () => {
158158

159159
// Mints private balances for two accounts, each on their own PXE. Verifies that querying
160160
// the balance for account A from PXE B returns 0 (key not registered), and vice versa.
161-
// TODO(F-741): `expectTokenBalance(walletB, token, accountAAddress, 0n)` throws
162-
// "No public key registered". Handshake discovery (get_shared_secrets) needs the scope's
163-
// keys, which this PXE lacks for a foreign account.
164-
it.skip('private state is zero when PXE does not have the account secret key', async () => {
161+
it('private state is zero when PXE does not have the account secret key', async () => {
165162
const userABalance = 100n;
166163
const userBBalance = 150n;
167164

yarn-project/end-to-end/src/automine/token/transfer.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,7 @@ describe('automine/token/transfer', () => {
6060

6161
// Transfers to a randomly generated non-deployed address. Because the recipient's keys aren't in the PXE,
6262
// the note can't be decrypted; TokenSimulator models this as a transfer to AztecAddress.ZERO.
63-
// TODO(F-741): the unconstrained delivery now establishes a non-interactive handshake, and checking the
64-
// non-deployed recipient's private balance throws "No public key registered". Handshake discovery
65-
// (get_shared_secrets) needs the scope's keys, which this PXE lacks for a foreign account.
66-
it.skip('transfer less than balance to non-deployed account', async () => {
63+
it('transfer less than balance to non-deployed account', async () => {
6764
const { result: balance0 } = await asset.methods.balance_of_private(adminAddress).simulate({ from: adminAddress });
6865
const amount = balance0 / 2n;
6966
expect(amount).toBeGreaterThan(0n);

yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,9 @@ describe('Utility Execution test suite', () => {
160160
return Promise.resolve(GrumpkinScalar.random());
161161
});
162162

163+
// Like the real AddressStore, resolve undefined for addresses never registered via registerAccount.
163164
addressStore.getCompleteAddress.mockImplementation((account: AztecAddress) => {
164-
if (account.equals(owner)) {
165-
return Promise.resolve(ownerCompleteAddress);
166-
}
167-
throw new Error(`Unknown address ${account}`);
165+
return Promise.resolve(account.equals(owner) ? ownerCompleteAddress : undefined);
168166
});
169167
});
170168

@@ -581,6 +579,17 @@ describe('Utility Execution test suite', () => {
581579
/expected/,
582580
);
583581
});
582+
583+
it('returns no secrets when the PXE does not hold the keys for the address', async () => {
584+
const ephSk = GrumpkinScalar.random();
585+
const ephPk = await Grumpkin.mul(Grumpkin.generator, ephSk);
586+
587+
const foreignAddress = await AztecAddress.random();
588+
const ephPksArray = EphemeralArray.fromValues<EmbeddedCurvePoint>(service, [ephPk]);
589+
const response = await utilityExecutionOracle.getSharedSecrets(foreignAddress, ephPksArray, contractAddress);
590+
591+
expect(response.readAll(service)).toEqual([]);
592+
});
584593
});
585594

586595
describe('getPendingTaggedLogs', () => {

yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -852,7 +852,8 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra
852852
* @param address - The recipient address.
853853
* @param ephPks - Ephemeral array containing the serialized Points.
854854
* @param contractAddress - The contract address for app-siloing (validated against execution context).
855-
* @returns A new ephemeral array containing the computed shared secrets.
855+
* @returns A new ephemeral array containing the computed shared secrets, or an empty array when the PXE does not
856+
* hold the keys for `address`, signaling that no secrets can be derived for it.
856857
*/
857858
public async getSharedSecrets(
858859
address: AztecAddress,
@@ -864,7 +865,17 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra
864865
`getSharedSecrets called with contract address ${contractAddress}, expected ${this.contractAddress}`,
865866
);
866867
}
867-
const recipientCompleteAddress = await this.getCompleteAddressOrFail(address);
868+
const recipientCompleteAddress = await this.addressStore.getCompleteAddress(address);
869+
if (!recipientCompleteAddress) {
870+
this.logger.warn(
871+
`Computing shared secrets for address ${address} whose keys are not held - returning no secrets`,
872+
{
873+
address,
874+
contractAddress: this.contractAddress,
875+
},
876+
);
877+
return EphemeralArray.fromValues<Fr>(this.ephemeralArrayService, []);
878+
}
868879
const ivpkMHash = await hashPublicKey(recipientCompleteAddress.publicKeys.ivpkM);
869880
const ivskM = await this.keyStore.getMasterSecretKey(ivpkMHash);
870881
const addressSecret = await computeAddressSecret(await recipientCompleteAddress.getPreaddress(), ivskM);

0 commit comments

Comments
 (0)