Skip to content
Merged
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/fresh-bears-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Accept zero-dollar proof credentials signed by authorized Tempo access keys and export Tempo proof DID helpers from `mppx/tempo`.
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'
123 changes: 123 additions & 0 deletions src/tempo/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import { signVoucher } from '../session/Voucher.js'
const realm = 'api.example.com'
const secretKey = 'test-secret-key'

type ProofAccessKeyContext = {
accessKey: ReturnType<typeof Account.fromSecp256k1>
rootAccount: (typeof accounts)[number]
}

const server = Mppx_server.create({
methods: [
tempo_server.charge({
Expand Down Expand Up @@ -2114,6 +2119,124 @@ describe('tempo', () => {
httpServer.close()
})

for (const testCase of [
{
name: 'accepts proof signed by an authorized access key for the root source',
expectedStatus: 200,
async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
await Actions.accessKey.authorizeSync(client, {
account: rootAccount,
accessKey,
feeToken: asset,
})
},
},
{
name: 'rejects proof signed by an unauthorized access key for the root source',
expectedDetail: 'Proof signature does not match source.',
expectedStatus: 402,
},
{
name: 'rejects proof signed by a revoked access key for the root source',
expectedDetail: 'Proof signature does not match source.',
expectedStatus: 402,
async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
await Actions.accessKey.authorizeSync(client, {
account: rootAccount,
accessKey,
feeToken: asset,
})
await fundAccount({ address: rootAccount.address, token: asset })
await Actions.accessKey.revokeSync(client, {
account: rootAccount,
accessKey,
feeToken: asset,
})
},
},
{
name: 'rejects proof signed by an expired access key for the root source',
expectedDetail: 'Proof signature does not match source.',
expectedStatus: 402,
async prepare({ accessKey, rootAccount }: ProofAccessKeyContext) {
await Actions.accessKey.authorizeSync(client, {
account: rootAccount,
accessKey,
expiry: Math.floor(Date.now() / 1000) + 10,
feeToken: asset,
})

const metadata = await Actions.accessKey.getMetadata(client, {
account: rootAccount.address,
accessKey,
})
const originalNow = Date.now
Date.now = () => (Number(metadata.expiry) + 5) * 1000

return () => {
Date.now = originalNow
}
},
},
] as const) {
test(`behavior: ${testCase.name}`, async () => {
const rootAccount = accounts[1]
const accessKey = Account.fromSecp256k1(Secp256k1.randomPrivateKey(), {
access: rootAccount,
})

let cleanup: (() => void) | undefined
let httpServer: Awaited<ReturnType<typeof Http.createServer>> | undefined

try {
const maybeCleanup = await testCase.prepare?.({ accessKey, rootAccount })
cleanup = typeof maybeCleanup === 'function' ? maybeCleanup : undefined

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(testCase.expectedStatus)

if (testCase.expectedDetail) {
const body = (await response2.json()) as { detail: string }
expect(body.detail).toContain(testCase.expectedDetail)
}
} finally {
cleanup?.()
httpServer?.close()
}
})
}

test('behavior: rejects replayed proof credential when store is configured', async () => {
const replayStore = Store.memory()
const server_ = Mppx_server.create({
Expand Down
75 changes: 74 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,63 @@ 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

const signer = SignatureEnvelope.extractAddress({
payload: keychainPayload,
signature: envelope.inner,
})
const valid = SignatureEnvelope.verify(envelope.inner, {
address: signer,
payload: keychainPayload,
})
if (!valid) return null

return signer
}

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