Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wallet-bound-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Bound Tempo zero-amount proof credentials to the payer wallet. The EIP-712 `Proof` typed-data now includes an `account` field (domain version bumped to `3`), so a proof signature commits to a specific payer address and can no longer be replayed against a different account — including across an access key authorized for multiple accounts. Exposed the canonical proof contract via `tempo.Proof` (`types`, `domain`, `primaryType`, `message`, `typedData`, `hash`) and added deterministic conformance vectors covering the wallet-binding property.
146 changes: 146 additions & 0 deletions src/tempo/Proof.conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { recoverTypedDataAddress } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { describe, expect, test } from 'vp/test'

import * as Proof from './Proof.js'

/**
* Deterministic conformance vector for the wallet-bound Tempo proof contract
* (EIP-712 domain `MPP` version `3`). These values pin the on-the-wire
* signing payload so any change to the proof ABI is caught here.
*/
const vector = {
account: '0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1',
chainId: 42431,
challengeId: 'kM9xPqWvT2nJrHsY4aDfEb',
digest: '0x3860a700a55e02ad3c2dc047e92489feceecbdb0a801d948e1d9f0b61ea9bc3f',
privateKey: `0x${'01'.repeat(32)}`,
realm: 'api.example.com',
signature:
'0x53f5d64d9f995e841b4212639b2e17e508e96752e10316df3814a16443dcbdb626c082190a4c3ecc3148101eb443d15bd83b579380b1be735a9c99f0df36c9fe1b',
} as const

const params = {
account: vector.account,
chainId: vector.chainId,
challengeId: vector.challengeId,
realm: vector.realm,
} as const

describe('tempo.Proof conformance (wallet binding)', () => {
test('typedData is the canonical wallet-bound MPP v3 proof contract', () => {
expect(Proof.typedData(params)).toEqual({
domain: { name: 'MPP', version: '3', chainId: vector.chainId },
types: {
Proof: [
{ name: 'account', type: 'address' },
{ name: 'challengeId', type: 'string' },
{ name: 'realm', type: 'string' },
],
},
primaryType: 'Proof',
message: {
account: vector.account,
challengeId: vector.challengeId,
realm: vector.realm,
},
})
})

test('hash matches the deterministic EIP-712 digest vector', () => {
expect(Proof.hash(params)).toBe(vector.digest)
})

test('the wallet produces the deterministic signature vector', async () => {
const account = privateKeyToAccount(vector.privateKey)
expect(account.address).toBe(vector.account)
const signature = await account.signTypedData(Proof.typedData(params))
expect(signature).toBe(vector.signature)
})

test('the signature vector recovers to the bound wallet', async () => {
const recovered = await recoverTypedDataAddress({
...Proof.typedData(params),
signature: vector.signature,
})
expect(recovered).toBe(vector.account)
})

test('the digest is bound to the wallet: a different account changes the digest', () => {
const other = '0x000000000000000000000000000000000000dEaD'
expect(Proof.hash({ ...params, account: other })).not.toBe(vector.digest)
})

test('a proof cannot be replayed against a different wallet for the same challenge', async () => {
const account = privateKeyToAccount(vector.privateKey)
const signature = await account.signTypedData(Proof.typedData(params))

// An attacker swaps the bound `account` to a wallet they want to impersonate
// while keeping the same challenge. Because `account` is a signed field, the
// recovered signer no longer matches the swapped wallet, so verification
// (and the access-key delegation check, which rebuilds the message from the
// claimed source) fails.
const swapped = '0x000000000000000000000000000000000000dEaD'
const recovered = await recoverTypedDataAddress({
...Proof.typedData({ ...params, account: swapped }),
signature,
})
expect(recovered).not.toBe(swapped)
expect(recovered).not.toBe(vector.account)
})

test('models the access-key delegation path: swapping the source breaks signer recovery', async () => {
// An access key K signs a proof bound to root account A. The server's
// delegation check recovers the signer from the message it rebuilds using
// the *claimed* source, then requires `isActiveAccessKey(signer, source)`.
// Distinct access key (signer) and root account (the bound payer / source).
const accessKey = privateKeyToAccount(`0x${'02'.repeat(32)}`)
const rootA = vector.account // proof is signed bound to account = A
const signature = await accessKey.signTypedData(Proof.typedData({ ...params, account: rootA }))
expect(accessKey.address).not.toBe(rootA)

// Honest submission (source = A): server recovers exactly K, so
// isActiveAccessKey(K, A) — the key actually authorized for A — is checked.
const recoveredForA = await recoverTypedDataAddress({
...Proof.typedData({ ...params, account: rootA }),
signature,
})
expect(recoveredForA).toBe(accessKey.address)

// Replay against a different root B (attacker swaps source to B): server
// rebuilds the message with account = B, recovering some K' != K. Even if K
// is an active access key of B, the server checks isActiveAccessKey(K', B),
// which cannot match the authorized key. Replay is rejected.
const rootB = '0x000000000000000000000000000000000000bEEF'
const recoveredForB = await recoverTypedDataAddress({
...Proof.typedData({ ...params, account: rootB }),
signature,
})
expect(recoveredForB).not.toBe(accessKey.address)
})

test('a legacy v2 proof (no account field) does not verify under the v3 contract', async () => {
// The pre-binding contract: domain version "2", message without `account`.
const account = privateKeyToAccount(vector.privateKey)
const legacyTypedData = {
domain: { name: 'MPP', version: '2', chainId: vector.chainId },
types: {
Proof: [
{ name: 'challengeId', type: 'string' },
{ name: 'realm', type: 'string' },
],
},
primaryType: 'Proof',
message: { challengeId: vector.challengeId, realm: vector.realm },
} as const
const legacySignature = await account.signTypedData(legacyTypedData)

// Verified against the current wallet-bound v3 contract, recovery yields a
// different address, so the server rejects stale v2 proofs.
const recovered = await recoverTypedDataAddress({
...Proof.typedData(params),
signature: legacySignature,
})
expect(recovered).not.toBe(account.address)
})
})
15 changes: 15 additions & 0 deletions src/tempo/Proof.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import type { Address, Hex } from 'viem'
import { expectTypeOf, test } from 'vp/test'

import { Proof } from './index.js'

test('Proof exports the wallet-bound typed-data contract helpers', () => {
expectTypeOf(Proof.message).toEqualTypeOf<
(parameters: { account: Address; challengeId: string; realm: string }) => {
readonly account: Address
readonly challengeId: string
readonly realm: string
}
>()

expectTypeOf(Proof.hash).toEqualTypeOf<
(parameters: { account: Address; chainId: number; challengeId: string; realm: string }) => Hex
>()
})

test('Proof exports public proof source helpers', () => {
expectTypeOf(Proof.proofSource).toEqualTypeOf<
(parameters: { address: string; chainId: number }) => string
Expand Down
53 changes: 52 additions & 1 deletion src/tempo/Proof.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,58 @@
import type { Address } from 'viem'
import type { Address, Hex } from 'viem'

import * as Proof_internal from './internal/proof.js'

/** EIP-712 primary type for Tempo proof credentials. */
export const primaryType = Proof_internal.primaryType

/**
* EIP-712 typed-data field definitions for Tempo zero-amount proof credentials.
*
* The `account` field cryptographically binds the signature to the payer
* wallet, so a proof signed for one account cannot be replayed against another.
*/
export const types = Proof_internal.types

/** Constructs the EIP-712 domain for a Tempo proof credential. */
export function domain(chainId: number) {
return Proof_internal.domain(chainId)
}

/**
* Constructs the EIP-712 message for a Tempo proof credential.
*
* @param parameters - Proof message parameters.
* @param parameters.account - Payer wallet address the proof is bound to.
* @param parameters.challengeId - Challenge `id` being proven.
* @param parameters.realm - Challenge `realm` being proven.
*/
export function message(parameters: { account: Address; challengeId: string; realm: string }) {
return Proof_internal.message(parameters)
}

/**
* Constructs the complete EIP-712 typed-data payload for a Tempo proof
* credential — the canonical, wallet-bound proof contract.
*/
export function typedData(parameters: {
account: Address
chainId: number
challengeId: string
realm: string
}) {
return Proof_internal.typedData(parameters)
}

/** Computes the EIP-712 digest (signing payload) for a Tempo proof credential. */
export function hash(parameters: {
account: Address
chainId: number
challengeId: string
realm: string
}): Hex {
return Proof_internal.hash(parameters)
}

/** Constructs the canonical `did:pkh:eip155` source DID for Tempo proof credentials. */
export function proofSource(parameters: { address: string; chainId: number }): string {
return Proof_internal.proofSource(parameters)
Expand Down
12 changes: 8 additions & 4 deletions src/tempo/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,14 @@ export function charge(parameters: charge.Parameters = {}) {
if (BigInt(amount) === 0n) {
const signature = await signTypedData(client, {
account,
domain: Proof.domain(chainId),
types: Proof.types,
primaryType: 'Proof',
message: Proof.message(challenge.id, challenge.realm),
// `account` here is the signing account; the proof's bound payer is
// `account.address` (echoed in the credential `source` below).
...Proof.typedData({
account: account.address,
chainId,
challengeId: challenge.id,
realm: challenge.realm,
}),
})
return Credential.serialize({
challenge,
Expand Down
16 changes: 12 additions & 4 deletions src/tempo/internal/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ const parsePkhSourceCases = [
] as const

describe('Proof', () => {
test('types has Proof with challengeId and realm fields', () => {
test('types has Proof with account, challengeId and realm fields', () => {
expect(Proof.types).toEqual({
Proof: [
{ name: 'account', type: 'address' },
{ name: 'challengeId', type: 'string' },
{ name: 'realm', type: 'string' },
],
Expand All @@ -55,16 +56,23 @@ describe('Proof', () => {

test('domain returns EIP-712 domain with name, version, chainId', () => {
const d = Proof.domain(42431)
expect(d).toEqual({ name: 'MPP', version: '2', chainId: 42431 })
expect(d).toEqual({ name: 'MPP', version: '3', chainId: 42431 })
})

test('domain uses provided chainId', () => {
expect(Proof.domain(1).chainId).toBe(1)
expect(Proof.domain(99999).chainId).toBe(99999)
})

test('message wraps challengeId and realm', () => {
expect(Proof.message('abc123', 'api.example.com')).toEqual({
test('message wraps account, challengeId and realm', () => {
expect(
Proof.message({
account: '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12',
challengeId: 'abc123',
realm: 'api.example.com',
}),
).toEqual({
account: '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12',
challengeId: 'abc123',
realm: 'api.example.com',
})
Expand Down
61 changes: 55 additions & 6 deletions src/tempo/internal/proof.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,70 @@
import { isAddress, type Address } from 'viem'
import { hashTypedData, isAddress, type Address, type Hex } from 'viem'

/** EIP-712 typed data types for proof credentials. */
/** EIP-712 primary type for proof credentials. */
export const primaryType = 'Proof' as const

/**
* EIP-712 typed-data field definitions for Tempo zero-amount proof credentials.
*
* The `account` field cryptographically binds the signature to the payer
* wallet, so a proof signed for one account cannot be replayed against another
* — including across an access key that is authorized for multiple accounts.
*/
export const types = {
Proof: [
{ name: 'account', type: 'address' },
{ name: 'challengeId', type: 'string' },
{ name: 'realm', type: 'string' },
],
} as const

/** Constructs the EIP-712 domain for a proof credential. */
export function domain(chainId: number) {
return { name: 'MPP', version: '2', chainId } as const
return { name: 'MPP', version: '3', chainId } as const
}

/**
* Constructs the EIP-712 message for a proof credential.
*
* @param parameters - Proof message parameters.
* @param parameters.account - Payer wallet address the proof is bound to.
* @param parameters.challengeId - Challenge `id` being proven.
* @param parameters.realm - Challenge `realm` being proven.
*/
export function message(parameters: { account: Address; challengeId: string; realm: string }) {
const { account, challengeId, realm } = parameters
return { account, challengeId, realm } as const
}

/**
* Constructs the complete EIP-712 typed-data payload for a proof credential.
*
* This is the canonical, wallet-bound proof contract: signing this payload
* commits the signer to a specific `account`, `challengeId`, and `realm`.
*/
export function typedData(parameters: {
account: Address
chainId: number
challengeId: string
realm: string
}) {
const { account, chainId, challengeId, realm } = parameters
return {
domain: domain(chainId),
types,
primaryType,
message: message({ account, challengeId, realm }),
} as const
}

/** Constructs the EIP-712 message for a proof credential. */
export function message(challengeId: string, realm: string) {
return { challengeId, realm } as const
/** Computes the EIP-712 digest (signing payload) for a proof credential. */
export function hash(parameters: {
account: Address
chainId: number
challengeId: string
realm: string
}): Hex {
return hashTypedData(typedData(parameters))
}

/** Constructs the expected `did:pkh` source DID for a proof credential. */
Expand Down
Loading
Loading