Skip to content
Closed
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/public-proof-source-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Expose public Tempo proof-source helpers so servers can parse `credential.source` without reimplementing the `did:pkh:eip155` format.
103 changes: 103 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Mppx_server from '../server/Mppx.js'
import { toNodeListener } from '../server/Mppx.js'
import * as Store from '../Store.js'
import { stripe as stripe_server } from '../stripe/server/Methods.js'
import * as TempoProof from '../tempo/Proof.js'
import { tempo } from '../tempo/server/Methods.js'
import type { SessionCredentialPayload } from '../tempo/session/Types.js'
import cli from './cli.js'
Expand Down Expand Up @@ -78,6 +79,40 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
return { output, stderr, exitCode }
}

function runTempo(args: string[]) {
const result = spawnSync('tempo', args, {
encoding: 'utf8',
cwd: path.resolve(import.meta.dirname, '../..'),
timeout: 120_000,
env: { ...process.env, NODE_NO_WARNINGS: '1' },
})

return {
status: result.status,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}

function getLiveTempoWalletIdentity() {
const result = runTempo(['wallet', '-t', 'whoami'])
if (result.status !== 0) {
throw new Error(`tempo wallet whoami failed with status ${result.status ?? 'unknown'}`)
}

if (!/ready:\s*true/.test(result.stdout)) {
throw new Error('tempo wallet is not ready.')
}

const walletAddress = result.stdout.match(/wallet:\s*"?(0x[0-9a-fA-F]{40})"?/)?.[1]
const keyAddress = result.stdout.match(/key:\s*\n\s+address:\s*"?(0x[0-9a-fA-F]{40})"?/m)?.[1]
if (!walletAddress || !keyAddress) {
throw new Error('Could not parse tempo wallet identity.')
}

return { keyAddress, walletAddress }
}

describe('discover validate', () => {
test('validates a local discovery document', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-discovery-'))
Expand Down Expand Up @@ -205,6 +240,74 @@ describe('discover validate', () => {
)
})

describe.skipIf(process.env.MPPX_TEST_LIVE_TEMPO_WALLET !== '1')(
'tempo wallet live integration',
() => {
test(
'happy path: delegated zero-amount proof is accepted by a live server when source is wallet DID',
{ timeout: 120_000 },
async () => {
const { walletAddress, keyAddress } = getLiveTempoWalletIdentity()
expect(walletAddress.toLowerCase()).not.toBe(keyAddress.toLowerCase())

const mainnetUsdc = '0x20C000000000000000000000b9537d11c60E8b50' as const
const server = Mppx_server.create({
methods: [
tempo.charge({
currency: mainnetUsdc,
recipient: accounts[0].address,
}),
],
realm: 'cli-live-tempo-wallet',
secretKey: 'cli-test-secret',
})

let authorization: string | undefined
const httpServer = await Http.createServer(async (req, res) => {
authorization = req.headers.authorization
const result = await toNodeListener(
server.charge({
amount: '0',
currency: mainnetUsdc,
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: accounts[0].address,
}),
)(req, res)
if (result.status === 402) return
res.end('live-wallet-proof-ok')
})
const liveUrl = httpServer.url.replace('localhost', '127.0.0.1')

try {
const request = runTempo(['request', '-s', liveUrl])
expect(request.status).toBe(0)
expect(request.stdout).toContain('live-wallet-proof-ok')
expect(authorization).toBeDefined()

const credential = Credential.deserialize<{ signature: string; type: 'proof' }>(
authorization!,
)
expect(credential.payload.type).toBe('proof')
expect(TempoProof.parseProofSource(credential.source!)).not.toBe(null)

const rewritten = Credential.serialize({
...credential,
source: `did:pkh:eip155:4217:${walletAddress}`,
})

const response = await fetch(liveUrl, {
headers: { Authorization: rewritten },
})
expect(response.status).toBe(200)
expect(await response.text()).toBe('live-wallet-proof-ok')
} finally {
httpServer.close()
}
},
)
},
)

describe('discover generate', () => {
test('generates from a pre-built OpenAPI document module', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mppx-generate-'))
Expand Down
13 changes: 13 additions & 0 deletions src/tempo/Proof.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expectTypeOf, test } from 'vp/test'

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

test('Proof exports public proof source helpers', () => {
expectTypeOf(Proof.proofSource).toEqualTypeOf<
(parameters: { address: string; chainId: number }) => string
>()

expectTypeOf(Proof.parseProofSource).toEqualTypeOf<
(source: string) => { address: `0x${string}`; chainId: number } | null
>()
})
31 changes: 31 additions & 0 deletions src/tempo/Proof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vp/test'

import * as tempo from './index.js'

describe('tempo.Proof', () => {
test('proofSource constructs a did:pkh:eip155 source', () => {
expect(
tempo.Proof.proofSource({
address: '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12',
chainId: 42431,
}),
).toBe('did:pkh:eip155:42431:0xAbCdEf1234567890AbCdEf1234567890AbCdEf12')
})

test('parseProofSource parses a valid did:pkh:eip155 source', () => {
expect(
tempo.Proof.parseProofSource(
'did:pkh:eip155:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
),
).toEqual({
address: '0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141',
chainId: 42431,
})
})

test('parseProofSource rejects invalid source values', () => {
expect(
tempo.Proof.parseProofSource('did:pkh:eip155:01:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141'),
).toBe(null)
})
})
13 changes: 13 additions & 0 deletions src/tempo/Proof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Address } from 'viem'

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

/** 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)
}

/** Parses a Tempo proof credential source DID into its chain ID and wallet address. */
export function parseProofSource(source: string): { address: Address; chainId: number } | null {
return Proof_internal.parseProofSource(source)
}
1 change: 1 addition & 0 deletions src/tempo/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * as Proof from './Proof.js'
export * as Methods from './Methods.js'
export * as Session from './session/index.js'
49 changes: 49 additions & 0 deletions src/tempo/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2114,6 +2114,55 @@ describe('tempo', () => {
httpServer.close()
})

test('behavior: accepts proof signed by an authorized access key for the root source', async () => {
const rootAccount = accounts[1]
const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
access: rootAccount,
})

await Actions.accessKey.authorizeSync(client, {
account: rootAccount,
accessKey,
feeToken: asset,
})

const httpServer = await Http.createServer(async (req, res) => {
const result = await Mppx_server.toNodeListener(
server.charge({ amount: '0', decimals: 6 }),
)(req, res)
if (result.status === 402) return
res.end('OK')
})

const response1 = await fetch(httpServer.url)
expect(response1.status).toBe(402)

const challenge = Challenge.fromResponse(response1, {
methods: [tempo_client.charge()],
})

const signature = await signTypedData(client, {
account: accessKey,
domain: Proof.domain(chain.id),
types: Proof.types,
primaryType: 'Proof',
message: Proof.message(challenge.id),
})

const credential = Credential.from({
challenge,
payload: { signature, type: 'proof' as const },
source: `did:pkh:eip155:${chain.id}:${rootAccount.address}`,
})

const response2 = await fetch(httpServer.url, {
headers: { Authorization: Credential.serialize(credential) },
})
expect(response2.status).toBe(200)

httpServer.close()
})

test('behavior: rejects replayed proof credential when store is configured', async () => {
const replayStore = Store.memory()
const server_ = Mppx_server.create({
Expand Down
68 changes: 67 additions & 1 deletion src/tempo/server/Charge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as SignatureEnvelope from 'ox/tempo/SignatureEnvelope'
import {
decodeFunctionData,
formatUnits,
hashTypedData,
keccak256,
parseEventLogs,
type TransactionReceipt,
Expand Down Expand Up @@ -227,7 +229,21 @@ export function charge<const parameters extends charge.Parameters>(
message: Proof.message(challenge.id),
signature: payload.signature as `0x${string}`,
})
if (!valid) throw new MismatchError('Proof signature does not match source.', {})
if (!valid) {
const proofSigner = recoverAuthorizedProofSigner({
chainId: resolvedChainId,
challengeId: challenge.id,
signature: payload.signature as `0x${string}`,
sourceAddress: source.address,
})
const authorized = proofSigner
? await isActiveAccessKey(client, {
accessKey: proofSigner,
account: source.address,
})
: false
if (!authorized) throw new MismatchError('Proof signature does not match source.', {})
}

if (proofStore && !(await markProofUsed(proofStore, challenge.id))) {
throw new VerificationFailedError({ reason: 'Proof credential has already been used' })
Expand Down Expand Up @@ -651,6 +667,56 @@ async function markProofUsed(
})
}

function recoverAuthorizedProofSigner(parameters: {
chainId: number
challengeId: string
signature: `0x${string}`
sourceAddress: `0x${string}`
}): `0x${string}` | null {
const { chainId, challengeId, signature, sourceAddress } = parameters

try {
const envelope = SignatureEnvelope.from(signature)
const proofHash = hashTypedData({
domain: Proof.domain(chainId),
types: Proof.types,
primaryType: 'Proof',
message: Proof.message(challengeId),
})

if (envelope.type === 'keychain') {
if (!TempoAddress.isEqual(envelope.userAddress, sourceAddress)) return null

const keychainPayload =
envelope.version === 'v2'
? keccak256(`0x04${proofHash.slice(2)}${sourceAddress.slice(2)}` as `0x${string}`)
: proofHash

return SignatureEnvelope.extractAddress({
payload: keychainPayload,
signature: envelope.inner,
})
}

return SignatureEnvelope.extractAddress({ payload: proofHash, signature: envelope })
} catch {
return null
}
}

async function isActiveAccessKey(
client: Awaited<ReturnType<ReturnType<typeof Client.getResolver>>>,
parameters: { account: `0x${string}`; accessKey: `0x${string}` },
): Promise<boolean> {
try {
const metadata = await Actions.accessKey.getMetadata(client, parameters)
const nowSeconds = BigInt(Math.floor(Date.now() / 1000))
return !metadata.isRevoked && metadata.expiry > nowSeconds
} catch {
return false
}
}

/** @internal */
function toReceipt(receipt: TransactionReceipt) {
const { status, transactionHash } = receipt
Expand Down
Loading