Skip to content

fix(policy): don't expose signer or key material through enforced handles - #75

Merged
jonathunne merged 2 commits into
mainfrom
fix/policy-proxy-internal-reference-exposure
Jul 29, 2026
Merged

fix(policy): don't expose signer or key material through enforced handles#75
jonathunne merged 2 commits into
mainfrom
fix/policy-proxy-internal-reference-exposure

Conversation

@AlonzoRicardo

Copy link
Copy Markdown
Contributor

Summary

The policy engine's enforced account (wdk.getAccount()) and its protocol proxies only substituted the named members (the OPERATIONS write methods, protocol verbs, simulate, registerProtocol). Every other property read fell through to the raw account via Reflect.get, so the guarded handle still surfaced:

  • keyPair → the private key
  • _signer → the raw signer (account._signer.signTransaction(...))
  • _provider / _config
  • a protocol's _account back-reference (account.getSwapProtocol(x)._account.sendTransaction(...))

Any of these lets a caller holding an "enforced" account sign or move funds without policy evaluation, defeating the guardrail the proxy exists to provide.

Fix

Both get traps now hide key material and internal references through one shared rule:

function isProtectedMember (prop) {
  return prop === 'keyPair' || (typeof prop === 'string' && prop.startsWith('_'))
}
  • keyPair is blocked by name — it's the one public getter that returns key material.
  • The underscore rule structurally covers _signer, _provider, _config, the ERC-4337 _ownerAccount, and a protocol's _account across every chain, rather than a per-name denylist that would miss chain-specific fields.
  • The prototype-climb variant (Object.getPrototypeOf(account).sendTransaction.call(account, …)) is neutralized by the same rule: every signing path funnels through this._signer, now undefined.

Unchanged: enforced write methods, protocol getters, simulate, registerProtocol, all reads (getBalance, quotes, verify, …), and identity accessors (path, index). instanceof still holds (no getPrototypeOf trap). Ungoverned accounts (no policy registered) are untouched.

The wallet classes' public API is deliberately not changed — the boundary is enforced only at the proxy, so direct (non-governed) users keep keyPair/_signer (no # private fields introduced).

Scope / trust boundary

This contains a caller holding an enforced account. It is not a defense against code that also holds the WalletManager/WDK instance or constructs an account directly — that remains the existing, documented trust boundary.

Tests

New internal-reference containment block covering: keyPair hidden, _signer/_provider/_config hidden, reads + enforced ops still work, and the protocol _account reach-through blocked while protocol enforcement still holds. Full suite 135 passing, standard clean.

@AlonzoRicardo
AlonzoRicardo force-pushed the fix/policy-proxy-internal-reference-exposure branch from e11471a to 0329f6e Compare July 8, 2026 15:47
@AlonzoRicardo AlonzoRicardo self-assigned this Jul 27, 2026

@jonathunne jonathunne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes direct reads, but still exposes the raw protected references.
i.e. account.signTransaction(...) is blocked by policy and account._signer is undefined, but Object.getOwnPropertyDescriptor(account, '_signer')?.value returns the raw signer and can still sign. The same applies to protocol proxies: protocol.swap(...) is blocked, but
Object.getOwnPropertyDescriptor(protocol, '_account')?.value.sendTransaction(...) still succeeds.
in and Reflect.ownKeys() also continue to reveal the protected members. Let's add protection for getOwnPropertyDescriptor, has, and ownKeys on both the account and protocol proxies.

…dles

The policy-enforced account and protocol proxies substituted only the named
write methods; every other property access fell through to the raw account.
That left `keyPair` (the private key), `_signer`, `_provider`, and a
protocol's `_account` back-reference reachable through the guarded handle, so
a caller holding an enforced account could sign outside policy evaluation
(e.g. `account.keyPair.privateKey`, `account._signer.signTransaction(...)`,
`account.getSwapProtocol(x)._account.sendTransaction(...)`).

Both get-traps now hide key material (`keyPair`) and internal references
(underscore-prefixed by WDK convention) via a shared `isProtectedMember`
check. Enforced methods, protocol getters, `simulate`, `registerProtocol`,
reads, and identity accessors (`path`/`index`) are unchanged; the
prototype-climb variant is neutralized because every signing path funnels
through the now-hidden `_signer`.

Adds regression coverage for the account and protocol proxies.
The `get` trap alone guards one of several reflective paths into a
governed handle. `Object.getOwnPropertyDescriptor` does not route
through `get`, so it still returned the raw member behind every
intercepted name:

- `...(account, '_signer').value` — the raw signer
- `...(account, '_account').value.privateKeyBuffer` — key material
- `...(account, 'getSwapProtocol').value(label)` — the *unenforced*
  protocol getter `_registerProtocols` installs as an own property,
  whose write methods skip policy evaluation entirely
- `...(protocol, '_account').value.sendTransaction(...)`

`has` and `ownKeys` also kept reporting protected members.

Both proxies were near-duplicates, so fold the four interception
categories (operations, protocol getters, `simulate`,
`registerProtocol`) into one substitutions map and route every trap
through it via a shared `createGuardedProxy`:

- `get` / `getOwnPropertyDescriptor` serve the enforced substitute,
  never the raw member. Descriptors are reported only for members the
  subject owns, so prototype methods still read as inherited.
- `get` / `getOwnPropertyDescriptor` / `has` / `ownKeys` treat
  protected members as absent.
- `preventExtensions` is refused: hiding own members is only a legal
  Proxy result while the target is extensible, so a caller must not be
  able to freeze it and turn later descriptor reads into TypeErrors.
@AlonzoRicardo
AlonzoRicardo force-pushed the fix/policy-proxy-internal-reference-exposure branch from 0329f6e to dbc5450 Compare July 29, 2026 11:25
@jonathunne
jonathunne merged commit 4ed0ab0 into main Jul 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants