Skip to content

Commit 5cd500e

Browse files
authored
feat!: remove fallback and signing keys from pxe (#24451)
Follow up of #24416. With this, PXE no longer has access to the message signing key nor the fallback keys, as should be (since the semantics of those keys require user approval before usage). To keep things simple, I removed the option of passing a seed from which all keys are derived to PXE and made it so the wallet must pass the privacy keys plus the message signing and fallback public keys. The wallet _does_ derive these from a seed, but that's a decision the wallet makes, PXE doesn't force it.
1 parent c2b51c6 commit 5cd500e

23 files changed

Lines changed: 268 additions & 227 deletions

File tree

docs/docs-developers/docs/foundational-topics/accounts/keys.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The nullifier hiding key (`nhk`) — sometimes referred to in older documentatio
6666
|---------|----------|----------------|
6767
| Private (constrained) | `context.request_nhk_app(owner_npk_m_hash)` | Called on `&mut PrivateContext` |
6868
| Unconstrained | `get_nhk_app(owner_npk_m_hash)` | `use aztec::keys::getters::get_nhk_app` |
69-
| TypeScript (master key) | `deriveMasterNullifierHidingKey(secretKey)` | `import { deriveMasterNullifierHidingKey } from '@aztec/aztec.js/keys'` |
69+
| TypeScript (master key) | `deriveMasterNullifierHidingSecretKey(secretKey)` | `import { deriveMasterNullifierHidingSecretKey } from '@aztec/aztec.js/keys'` |
7070
| TypeScript (app-siloed) | `computeAppNullifierHidingKey(nhkM, app)` | `import { computeAppNullifierHidingKey } from '@aztec/aztec.js/keys'` |
7171

7272
To get the owner's master nullifier public key hash (needed as input):
@@ -75,7 +75,7 @@ To get the owner's master nullifier public key hash (needed as input):
7575
let owner_npk_m_hash = get_public_keys(owner).npk_m_hash;
7676
```
7777

78-
`PublicKeys` exposes the nullifier, outgoing-viewing, and tagging keys directly as their hashes; only `ivpk_m` is held as a Grumpkin point (it is required as a point for address derivation and encrypt-to-address).
78+
`PublicKeys` exposes the nullifier, outgoing-viewing, tagging, message-signing, and fallback keys as their hashes; only `ivpk_m` is held as a Grumpkin point (it is required as a point for address derivation and encrypt-to-address).
7979

8080
:::warning
8181
Do not compute nullifier keys by hand or derive custom blinding factors. The protocol kernel validates that `nhk_app` derives correctly from the master key — a hand-rolled value will fail verification.
@@ -159,7 +159,7 @@ The `Ovpk` (outgoing viewing key) and `Tpk` (tagging key) exist in the protocol'
159159
:::
160160

161161
:::note
162-
The `Mspk` (master message-signing key) and `Fbpk` (master fallback key) hash slots exist in `PublicKeys` and participate in the address derivation above, but there is **no canonical derivation path for them yet**. `deriveKeys(secretKey)` stamps the canonical default hashes (`DEFAULT_MSPK_M_HASH`, `DEFAULT_FBPK_M_HASH`) into every account, so today they contribute no per-account entropy. When a real derivation lands in a future release, the address derived from the same secret will change — see the migration notes.
162+
The `Mspk` (master message-signing key) and `Fbpk` (master fallback key) are reserved for future protocol features (message signing and account recovery, respectively). Their public keys are derived per account and participate in the address derivation above. Unlike the other privacy keys, their **secret keys are held by the wallet, not the PXE**: the PXE receives only the corresponding public keys, since it is not trusted to hold these secrets.
163163
:::
164164

165165
## Key Management
@@ -215,9 +215,9 @@ Aztec's multi-key architecture is fundamental to its privacy and security model:
215215
| **Incoming Viewing** (`Ivpk_m`) | Decrypt received notes | No | No | Protocol (PXE) |
216216
| **Outgoing Viewing** (`Ovpk_m`) | Reserved | N/A | No | Protocol (PXE) |
217217
| **Tagging** (`Tpk_m`) | Reserved | N/A | No | Protocol (PXE) |
218-
| **Message-Signing** (`Mspk_m`) | Reserved | N/A | No | Protocol (PXE) |
219-
| **Fallback** (`Fbpk_m`) | Reserved | N/A | No | Protocol (PXE) |
220-
| **Signing** | Transaction authorization | N/A | Yes | Application |
218+
| **Message-Signing** (`Mspk_m`) | Reserved | N/A | No | Application (Wallet) |
219+
| **Fallback** (`Fbpk_m`) | Reserved | N/A | No | Application (Wallet) |
220+
| **Signing** | Transaction authorization | N/A | Yes | Account Contract (Wallet) |
221221

222222
**Key takeaways:**
223223

docs/docs-developers/docs/resources/migration_notes.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ Aztec is in active development. Each version may introduce breaking changes that
2727
+ const secretKey = accountManager.getSecretKey();
2828
```
2929

30-
To do what `AccountWithSecretKey` was meant for (exporting an account into a separate PXE or wallet), account registration now also accepts a full set of master secret keys instead of only a single seed. `wallet.registerContract(instance, artifact?, secretKeyOrKeys?)` and `pxe.registerAccount(secretKeyOrKeys, partialAddress)` take either an `Fr` (as before) or a `MasterSecretKeys` object (exported from `@aztec/aztec.js/keys`), and `pxe.getAccountSecretKeys(address)` returns those keys. These keys guard the account's privacy, so they should never be exposed to applications.
30+
To do what `AccountWithSecretKey` was meant for (exporting an account into a separate PXE or wallet), account registration accepts a full set of master secret keys instead of only a single seed. `wallet.registerContract(instance, artifact?, secretKeyOrKeys?)` takes either an `Fr` seed (as before) or a `MasterSecretKeys` object (exported from `@aztec/aztec.js/keys`), for an account whose privacy keys were generated independently rather than from one seed.
3131

32-
**Impact**: Importing `AccountWithSecretKey`, or calling `getSecretKey()`/`getEncryptionSecret()` on the result of `getAccount()`, no longer compiles. The signer `getAccount()` returns is otherwise unchanged, and passing a single `Fr` to the registration methods keeps working.
32+
The PXE never receives the seed nor the message-signing and fallback secret keys: it is not trusted to hold them. The wallet derives the account's privacy keys and passes the PXE only the four privacy secret keys (nullifier-hiding, incoming-viewing, outgoing-viewing, tagging) plus the message-signing and fallback *public* keys. Accordingly, `pxe.getAccountSecretKeys(address)` returns only those four privacy secret keys.
33+
34+
**Impact**: Importing `AccountWithSecretKey`, or calling `getSecretKey()`/`getEncryptionSecret()` on the result of `getAccount()`, no longer compiles. The signer `getAccount()` returns is otherwise unchanged, and passing a single `Fr` or a `MasterSecretKeys` to `wallet.registerContract` keeps working.
3335

3436

3537
### [PXE] Unconstrained delivery defaults to a non-interactive handshake for external recipients

docs/docs-developers/docs/tutorials/js_tutorials/wallet-extension/04-accounts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function deriveSigningKey(secretKey: Fr): GrumpkinScalar {
4444
return sha512ToGrumpkinScalar([secretKey, GeneratorIndex.IVSK_M]);
4545
}
4646

47-
export function deriveMasterNullifierHidingKey(secretKey: Fr): GrumpkinScalar {
47+
export function deriveMasterNullifierHidingSecretKey(secretKey: Fr): GrumpkinScalar {
4848
return sha512ToGrumpkinScalar([secretKey, GeneratorIndex.NHK_M]);
4949
}
5050
```

yarn-project/aztec.js/src/api/keys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export {
33
computeAppNullifierHidingKey,
44
deriveKeys,
55
deriveMasterIncomingViewingSecretKey,
6-
deriveMasterNullifierHidingKey,
6+
deriveMasterNullifierHidingSecretKey,
77
} from '@aztec/stdlib/keys';
88
export { generatePublicKey } from '../utils/pub_key.js';

yarn-project/end-to-end/src/automine/accounts/keys.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { siloNullifier } from '@aztec/stdlib/hash';
1010
import {
1111
computeAppNullifierHidingKey,
1212
computeAppSecretKey,
13-
deriveMasterNullifierHidingKey,
13+
deriveMasterNullifierHidingSecretKey,
1414
deriveMasterOutgoingViewingSecretKey,
1515
derivePublicKeyFromSecretKey,
1616
hashPublicKey,
@@ -74,8 +74,8 @@ describe('automine/accounts/keys', () => {
7474
// Creates a note, asserts 0 nullified notes. Destroys the note, scans all blocks for matching
7575
// nullifiers derived from nhk_app and asserts exactly 1 nullified note.
7676
it('nhk_app and contract address are enough to detect note nullification', async () => {
77-
const masterNullifierHidingKey = deriveMasterNullifierHidingKey(secret);
78-
const nhkApp = await computeAppNullifierHidingKey(masterNullifierHidingKey, testContract.address);
77+
const masterNullifierHidingSecretKey = deriveMasterNullifierHidingSecretKey(secret);
78+
const nhkApp = await computeAppNullifierHidingKey(masterNullifierHidingSecretKey, testContract.address);
7979

8080
const noteValue = 5;
8181
const noteStorageSlot = 12;

yarn-project/end-to-end/src/automine/card_game.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { generateSchnorrAccounts } from '@aztec/accounts/testing';
22
import { AztecAddress } from '@aztec/aztec.js/addresses';
33
import type { GrumpkinScalar } from '@aztec/aztec.js/fields';
4-
import { computeAppNullifierHidingKey, deriveMasterNullifierHidingKey } from '@aztec/aztec.js/keys';
4+
import { computeAppNullifierHidingKey, deriveMasterNullifierHidingSecretKey } from '@aztec/aztec.js/keys';
55
import type { Logger } from '@aztec/aztec.js/log';
66
import type { Wallet } from '@aztec/aztec.js/wallet';
77
import { toBufferLE } from '@aztec/foundation/bigint-buffer';
@@ -68,7 +68,7 @@ describe('automine/card_game', () => {
6868
let teardown: () => Promise<void>;
6969

7070
let wallet: TestWallet;
71-
let masterNullifierHidingKeys: GrumpkinScalar[];
71+
let masterNullifierHidingSecretKeys: GrumpkinScalar[];
7272

7373
let firstPlayer: AztecAddress;
7474
let secondPlayer: AztecAddress;
@@ -78,8 +78,8 @@ describe('automine/card_game', () => {
7878

7979
const getPackedCards = async (accountIndex: number, seed: bigint): Promise<Card[]> => {
8080
// First we get the app nullifier hiding key for the account
81-
const masterNullifierHidingKey = masterNullifierHidingKeys[accountIndex];
82-
const appNullifierHidingKey = await computeAppNullifierHidingKey(masterNullifierHidingKey, contract.address);
81+
const masterNullifierHidingSecretKey = masterNullifierHidingSecretKeys[accountIndex];
82+
const appNullifierHidingKey = await computeAppNullifierHidingKey(masterNullifierHidingSecretKey, contract.address);
8383
// Then we compute the mix from it and hash it to get the random bytes the same way as in the contract
8484
const mix = appNullifierHidingKey.toBigInt() + seed;
8585
const randomBytes = sha256(toBufferLE(mix, 32));
@@ -106,7 +106,7 @@ describe('automine/card_game', () => {
106106
}
107107
[firstPlayer, secondPlayer, thirdPlayer] = players.map(p => p.address);
108108

109-
masterNullifierHidingKeys = players.map(({ secret }) => deriveMasterNullifierHidingKey(secret));
109+
masterNullifierHidingSecretKeys = players.map(({ secret }) => deriveMasterNullifierHidingSecretKey(secret));
110110
});
111111

112112
beforeEach(async () => {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin';
2+
import type { PublicKey } from '@aztec/stdlib/keys';
3+
4+
/**
5+
* The four master privacy secret keys the key store holds for an account: the nullifier-hiding, incoming-viewing,
6+
* outgoing-viewing, and tagging keys.
7+
*/
8+
export type AccountPrivacySecretKeys = {
9+
masterNullifierHidingSecretKey: GrumpkinScalar;
10+
masterIncomingViewingSecretKey: GrumpkinScalar;
11+
masterOutgoingViewingSecretKey: GrumpkinScalar;
12+
masterTaggingSecretKey: GrumpkinScalar;
13+
};
14+
15+
/**
16+
* The keys needed to register an account: the four privacy secret keys the key store holds, plus the *public*
17+
* message-signing and fallback keys. The message-signing and fallback secret keys are withheld from the key store (and
18+
* hence from PXE, which embeds it), since it is not trusted to hold them: only their public keys are needed (to
19+
* reconstruct the account's address).
20+
*/
21+
export type AccountPrivacyKeys = AccountPrivacySecretKeys & {
22+
masterMessageSigningPublicKey: PublicKey;
23+
masterFallbackPublicKey: PublicKey;
24+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './account_privacy_keys.js';
12
export * from './key_store.js';

yarn-project/key-store/src/key_store.test.ts

Lines changed: 37 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,18 @@ import { Fr } from '@aztec/foundation/curves/bn254';
22
import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin';
33
import { openTmpStore } from '@aztec/kv-store/lmdb-v2';
44
import { AztecAddress } from '@aztec/stdlib/aztec-address';
5-
import { type MasterSecretKeys, deriveKeys, derivePublicKeyFromSecretKey, hashPublicKey } from '@aztec/stdlib/keys';
5+
import { PublicKey, deriveKeys, derivePublicKeyFromSecretKey, hashPublicKey } from '@aztec/stdlib/keys';
66

7+
import type { AccountPrivacySecretKeys } from './account_privacy_keys.js';
78
import { KeyStore } from './key_store.js';
89

9-
/** Picks the six master secret keys out of the full `deriveKeys` output. */
10-
function masterSecretKeysOf(derived: Awaited<ReturnType<typeof deriveKeys>>): MasterSecretKeys {
11-
return {
12-
masterNullifierHidingKey: derived.masterNullifierHidingKey,
13-
masterIncomingViewingSecretKey: derived.masterIncomingViewingSecretKey,
14-
masterOutgoingViewingSecretKey: derived.masterOutgoingViewingSecretKey,
15-
masterTaggingSecretKey: derived.masterTaggingSecretKey,
16-
masterMessageSigningSecretKey: derived.masterMessageSigningSecretKey,
17-
masterFallbackSecretKey: derived.masterFallbackSecretKey,
18-
};
19-
}
10+
/** The four privacy secret keys the key store holds and returns from `getAccountSecretKeys`. */
11+
const PRIVACY_SECRET_KEY_NAMES = [
12+
'masterNullifierHidingSecretKey',
13+
'masterIncomingViewingSecretKey',
14+
'masterOutgoingViewingSecretKey',
15+
'masterTaggingSecretKey',
16+
] as const satisfies readonly (keyof AccountPrivacySecretKeys)[];
2017

2118
describe('KeyStore', () => {
2219
it('Adds account and returns keys', async () => {
@@ -25,22 +22,24 @@ describe('KeyStore', () => {
2522
// Arbitrary fixed values
2623
const sk = new Fr(8923n);
2724
const keys = await deriveKeys(sk);
28-
const derivedMasterNullifierPublicKey = await derivePublicKeyFromSecretKey(keys.masterNullifierHidingKey);
29-
const computedMasterNullifierPublicKeyHash = await hashPublicKey(derivedMasterNullifierPublicKey);
25+
const derivedMasterNullifierHidingPublicKey = await derivePublicKeyFromSecretKey(
26+
keys.masterNullifierHidingSecretKey,
27+
);
28+
const computedMasterNullifierHidingPublicKeyHash = await hashPublicKey(derivedMasterNullifierHidingPublicKey);
3029
const computedMasterIncomingViewingPublicKeyHash = await hashPublicKey(keys.publicKeys.ivpkM);
3130

3231
const partialAddress = new Fr(243523n);
3332

34-
const { address: accountAddress } = await keyStore.addAccount(sk, partialAddress);
33+
const { address: accountAddress } = await keyStore.addAccount(keys, partialAddress);
3534
expect(accountAddress.toString()).toMatchInlineSnapshot(
3635
`"0x0a3120bded2afb430e67e4bdb5326a673fbfd95642b6ea7f80d0cc958aac3940"`,
3736
);
3837

3938
const { pkMHash: returnedNpkMHash } = await keyStore.getKeyValidationRequest(
40-
computedMasterNullifierPublicKeyHash,
39+
computedMasterNullifierHidingPublicKeyHash,
4140
await AztecAddress.random(), // Address is random because we are not interested in the app secret key here
4241
);
43-
expect(returnedNpkMHash.equals(computedMasterNullifierPublicKeyHash)).toBe(true);
42+
expect(returnedNpkMHash.equals(computedMasterNullifierHidingPublicKeyHash)).toBe(true);
4443

4544
const masterIncomingViewingPublicKey = await keyStore.getMasterIncomingViewingPublicKey(accountAddress);
4645
expect(masterIncomingViewingPublicKey.equals(keys.publicKeys.ivpkM)).toBe(true);
@@ -58,13 +57,13 @@ describe('KeyStore', () => {
5857
const appAddress = AztecAddress.fromBigIntUnsafe(624n);
5958

6059
const { pkMHash: obtainedNpkMHash, skApp: appNullifierHidingKey } = await keyStore.getKeyValidationRequest(
61-
computedMasterNullifierPublicKeyHash,
60+
computedMasterNullifierHidingPublicKeyHash,
6261
appAddress,
6362
);
6463
expect(appNullifierHidingKey.toString()).toMatchInlineSnapshot(
6564
`"0x165cc265d187ed42f0e3f5adbb5a0055a77e205daeb68dd1735796ee402e502f"`,
6665
);
67-
expect(obtainedNpkMHash).toEqual(computedMasterNullifierPublicKeyHash);
66+
expect(obtainedNpkMHash).toEqual(computedMasterNullifierHidingPublicKeyHash);
6867

6968
const appOutgoingViewingSecretKey = await keyStore.getAppOutgoingViewingSecretKey(accountAddress, appAddress);
7069
expect(appOutgoingViewingSecretKey.toString()).toMatchInlineSnapshot(
@@ -78,8 +77,10 @@ describe('KeyStore', () => {
7877
);
7978

8079
// Manages to find master nullifier hiding key for the pk_m hash
81-
const masterNullifierHidingKey = await keyStore.getMasterSecretKey(computedMasterNullifierPublicKeyHash);
82-
expect(masterNullifierHidingKey.equals(keys.masterNullifierHidingKey)).toBe(true);
80+
const masterNullifierHidingSecretKey = await keyStore.getMasterSecretKey(
81+
computedMasterNullifierHidingPublicKeyHash,
82+
);
83+
expect(masterNullifierHidingSecretKey.equals(keys.masterNullifierHidingSecretKey)).toBe(true);
8384

8485
// Manages to find master incoming viewing secret key for the pk_m hash
8586
const masterIncomingViewingSecretKeyFromPublicKey = await keyStore.getMasterSecretKey(
@@ -88,38 +89,33 @@ describe('KeyStore', () => {
8889
expect(masterIncomingViewingSecretKeyFromPublicKey.equals(keys.masterIncomingViewingSecretKey)).toBe(true);
8990
});
9091

91-
it('registers an account from master secret keys, matching seed-based registration', async () => {
92+
it('exports the privacy secret keys it was registered with', async () => {
9293
const keyStore = new KeyStore(await openTmpStore('test'));
9394

94-
const sk = new Fr(8923n);
95-
const partialAddress = new Fr(243523n);
96-
const secretKeys = masterSecretKeysOf(await deriveKeys(sk));
95+
const privacyKeys = await deriveKeys(new Fr(8923n));
96+
const { address } = await keyStore.addAccount(privacyKeys, new Fr(243523n));
9797

98-
// Registering with the keys derived from a secret must yield the same complete address as registering with the
99-
// secret directly: the address is a pure function of the (derived) public keys and the partial address.
100-
const fromSecretKey = await keyStore.addAccount(sk, partialAddress);
101-
const fromKeys = await keyStore.addAccount(secretKeys, partialAddress);
102-
expect(fromKeys.equals(fromSecretKey)).toBe(true);
98+
const exported = await keyStore.getAccountSecretKeys(address);
99+
for (const name of PRIVACY_SECRET_KEY_NAMES) {
100+
expect(exported[name].equals(privacyKeys[name])).toBe(true);
101+
}
103102
});
104103

105-
it('exports the master secret keys it was registered with', async () => {
104+
it('rejects registering an account with secret key resulting in infinity public keys', async () => {
106105
const keyStore = new KeyStore(await openTmpStore('test'));
107106

108-
const secretKeys = masterSecretKeysOf(await deriveKeys(new Fr(8923n)));
109-
const { address } = await keyStore.addAccount(secretKeys, new Fr(243523n));
107+
const privacyKeys = await deriveKeys(new Fr(8923n));
108+
privacyKeys.masterIncomingViewingSecretKey = GrumpkinScalar.ZERO;
110109

111-
const exported = await keyStore.getAccountSecretKeys(address);
112-
for (const name of Object.keys(secretKeys) as (keyof MasterSecretKeys)[]) {
113-
expect(exported[name].equals(secretKeys[name])).toBe(true);
114-
}
110+
await expect(keyStore.addAccount(privacyKeys, new Fr(243523n))).rejects.toThrow('masterIncomingViewingPublicKey');
115111
});
116112

117-
it('rejects registering an account with a zero secret key', async () => {
113+
it('rejects registering an account with an infinity message-signing or fallback public key', async () => {
118114
const keyStore = new KeyStore(await openTmpStore('test'));
119115

120-
const secretKeys = masterSecretKeysOf(await deriveKeys(new Fr(8923n)));
121-
secretKeys.masterIncomingViewingSecretKey = GrumpkinScalar.ZERO;
116+
const privacyKeys = await deriveKeys(new Fr(8923n));
117+
privacyKeys.masterMessageSigningPublicKey = PublicKey.INFINITY;
122118

123-
await expect(keyStore.addAccount(secretKeys, new Fr(243523n))).rejects.toThrow('masterIncomingViewingSecretKey');
119+
await expect(keyStore.addAccount(privacyKeys, new Fr(243523n))).rejects.toThrow('masterMessageSigningPublicKey');
124120
});
125121
});

0 commit comments

Comments
 (0)