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
49 changes: 42 additions & 7 deletions packages/core/wallet/src/smart-wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
SmartWalletWebAuthnProvider,
USDCNetwork,
} from './types/smart-wallet.types';
import { USDC_ISSUERS } from './types/smart-wallet.types';
import { USDC_ISSUERS, SignatureExpiredException } from './types/smart-wallet.types';

// ---------------------------------------------------------------------------
// TTL helpers
Expand Down Expand Up @@ -373,7 +373,8 @@ export class SmartWalletService {
private rpcUrl: string,
factoryId?: string,
private network: string = Networks.TESTNET,
credentialBackend: CredentialBackend = new BrowserCredentialBackend()
credentialBackend: CredentialBackend = new BrowserCredentialBackend(),
private expirationBufferLedgers: number = 10
) {
this.server = new Server(rpcUrl);
this.credentialBackend = credentialBackend;
Expand All @@ -382,6 +383,28 @@ export class SmartWalletService {
}
}

/**
* Validates that a Soroban auth entry's signatureExpirationLedger is not
* already expired or within the configured expiration buffer.
*
* @throws {SignatureExpiredException} when expiration is too close or passed.
*/
private validateSignatureExpiration(
authEntry: xdr.SorobanAuthorizationEntry,
currentLedger: number
): void {
const credentials = authEntry.credentials();
// Only address credentials carry a signatureExpirationLedger.
// Source-account credentials use no expiry, so skip them.
if (credentials.switch().name !== 'sorobanCredentialsAddress') {
return;
}
const expirationLedger = credentials.address().signatureExpirationLedger();
if (expirationLedger <= currentLedger + this.expirationBufferLedgers) {
throw new SignatureExpiredException(expirationLedger, currentLedger);
}
}

// -------------------------------------------------------------------------
// addSigner()
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -493,6 +516,7 @@ export class SmartWalletService {
}

const authEntry: xdr.SorobanAuthorizationEntry = simResult.result.auth[0];
this.validateSignatureExpiration(authEntry, sequence);
const authEntryBytes = authEntry.toXDR();
const authEntryArrayBuffer = authEntryBytes.buffer.slice(
authEntryBytes.byteOffset,
Expand Down Expand Up @@ -611,6 +635,7 @@ export class SmartWalletService {
}

const authEntry: xdr.SorobanAuthorizationEntry = simResult.result.auth[0];
this.validateSignatureExpiration(authEntry, sequence);
const authEntryBytes = authEntry.toXDR();
const authEntryArrayBuffer = authEntryBytes.buffer.slice(
authEntryBytes.byteOffset,
Expand Down Expand Up @@ -749,6 +774,7 @@ export class SmartWalletService {
}

const authEntry: xdr.SorobanAuthorizationEntry = simResult.result.auth[0];
this.validateSignatureExpiration(authEntry, sequence);

let assertionResponse: AuthenticatorAssertionResponse;
let signingCredentialIdBytes: Uint8Array;
Expand Down Expand Up @@ -839,11 +865,16 @@ export class SmartWalletService {
throw new Error('Simulation returned no auth entries.');
}

const { sequence: currentLedger } = await this.server.getLatestLedger();

// Process all authorization entries
for (let i = 0; i < simResult.result.auth.length; i++) {
const authEntry: xdr.SorobanAuthorizationEntry = simResult.result.auth[i];

// 1. Validate DeFi authorization entries (Soroswap, etc.)
// 1. Validate signature expiration before signing
this.validateSignatureExpiration(authEntry, currentLedger);

// 2. Validate DeFi authorization entries (Soroswap, etc.)
this.validateDeFiAuthorization(authEntry, contractAddress);

// 2. Obtain Passkey signature
Expand Down Expand Up @@ -1015,12 +1046,16 @@ export class SmartWalletService {

const authEntry: xdr.SorobanAuthorizationEntry = simResult.result.auth[0];

// 2. Compute the 32-byte auth-entry hash (= signature_payload in __check_auth)
// 2. Validate signature expiration before signing
const { sequence: currentLedger } = await this.server.getLatestLedger();
this.validateSignatureExpiration(authEntry, currentLedger);

// 3. Compute the 32-byte auth-entry hash (= signature_payload in __check_auth)
const authEntryHash = Buffer.from(
await crypto.subtle.digest('SHA-256', getAuthEntryArrayBuffer(authEntry))
);

// 3. Invoke the caller's sign callback with the hash
// 4. Invoke the caller's sign callback with the hash
const ed25519Sig = signFn(authEntryHash);

if (!ed25519Sig || ed25519Sig.byteLength !== 64) {
Expand All @@ -1029,14 +1064,14 @@ export class SmartWalletService {
);
}

// 4. Build AccountSignature::SessionKey ScVal
// 5. Build AccountSignature::SessionKey ScVal
const credentialIdBytes = base64UrlToUint8Array(credentialId);
const signerSignature = buildSessionKeySignatureScVal(
credentialIdBytes,
ed25519Sig
);

// 5. Attach to auth entry and assemble XDR
// 6. Attach to auth entry and assemble XDR
attachSignatureToAuthEntry(authEntry, contractAddress, signerSignature);
simResult.result.auth[0] = authEntry;

Expand Down
169 changes: 167 additions & 2 deletions packages/core/wallet/src/tests/smart-wallet.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
SmartWalletService,
ttlSecondsToLedgers,
} from '../smart-wallet.service';
import { SignatureExpiredException } from '../types/smart-wallet.types';
import { Transaction, xdr } from '@stellar/stellar-sdk';
import { Api } from '@stellar/stellar-sdk/rpc';
import type { CredentialBackend } from '../types/smart-wallet.types';
Expand Down Expand Up @@ -113,7 +114,7 @@ const MOCK_SESSION_PUBLIC_KEY =
const MOCK_CREDENTIAL_ID = Buffer.from('test-credential-id').toString('base64');
const TTL_SECONDS = 3600;

function makeAuthEntry() {
function makeAuthEntry(signatureExpirationLedger = 9999) {
const entry = {
toXDR: jest.fn(() => Buffer.alloc(32, 0xab)),
credentials: jest.fn(),
Expand All @@ -129,9 +130,10 @@ function makeAuthEntry() {
} as unknown as xdr.SorobanAuthorizationEntry;

(entry.credentials as jest.Mock).mockReturnValue({
switch: () => ({ name: 'sorobanCredentialsAddress' }),
address: () => ({
nonce: () => 0n,
signatureExpirationLedger: () => 9999,
signatureExpirationLedger: () => signatureExpirationLedger,
}),
});

Expand Down Expand Up @@ -950,6 +952,169 @@ describe('SmartWalletService', () => {
});
});

// =========================================================================
// Signature expiration validation
// =========================================================================

describe('signature expiration validation', () => {
// Current ledger returned by getLatestLedger
const CURRENT_LEDGER = 1000;
// Default buffer is 10 ledgers
const BUFFER = 10;

beforeEach(() => {
mockServer.getLatestLedger.mockResolvedValue({ sequence: CURRENT_LEDGER });
});

describe('sign() expiration checks', () => {
it('succeeds when signatureExpirationLedger is well beyond the buffer', async () => {
const authEntry = makeAuthEntry(CURRENT_LEDGER + BUFFER + 1); // 1011
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).resolves.toBeDefined();
});

it('throws SignatureExpiredException when signatureExpirationLedger has already passed', async () => {
const expiredLedger = CURRENT_LEDGER - 1; // 999 β€” already in the past
const authEntry = makeAuthEntry(expiredLedger);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).rejects.toThrow(SignatureExpiredException);
});

it('throws SignatureExpiredException when signatureExpirationLedger is within the buffer', async () => {
const nearlyExpiredLedger = CURRENT_LEDGER + BUFFER; // exactly at boundary
const authEntry = makeAuthEntry(nearlyExpiredLedger);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).rejects.toThrow(SignatureExpiredException);
});

it('includes expirationLedger and currentLedger on the thrown error', async () => {
const expiredLedger = CURRENT_LEDGER - 5;
const authEntry = makeAuthEntry(expiredLedger);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).rejects.toMatchObject({
expirationLedger: expiredLedger,
currentLedger: CURRENT_LEDGER,
});
});

it('skips expiration check for non-address credentials', async () => {
const authEntry = makeAuthEntry(CURRENT_LEDGER - 1);
// Override switch to return source-account variant.
// address() must still be present for attachSignatureToAuthEntry.
(authEntry.credentials as jest.Mock).mockReturnValue({
switch: () => ({ name: 'sorobanCredentialsSourceAccount' }),
address: () => ({
nonce: () => 0n,
signatureExpirationLedger: () => CURRENT_LEDGER - 1,
}),
});
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

// Should not throw β€” source-account credentials have no expiry field to check
await expect(
service.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).resolves.toBeDefined();
});

it('respects a custom expirationBufferLedgers passed to the constructor', async () => {
const customBuffer = 50;
const customService = new SmartWalletService(
{ relyingPartyId: 'localhost' },
'https://rpc.example.com',
undefined,
undefined,
mockCredentialBackend,
customBuffer
);

// Expiration is beyond default buffer (10) but within custom buffer (50)
const expirationLedger = CURRENT_LEDGER + 30; // 1030
const authEntry = makeAuthEntry(expirationLedger);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
customService.sign(MOCK_CONTRACT_ADDRESS, sorobanTx, 'Y3JlZElk')
).rejects.toThrow(SignatureExpiredException);
});
});

describe('signWithSessionKey() expiration checks', () => {
const signFn = () => Buffer.alloc(64, 0xaa);

it('succeeds when signatureExpirationLedger is well beyond the buffer', async () => {
const authEntry = makeAuthEntry(CURRENT_LEDGER + BUFFER + 1);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.signWithSessionKey(
MOCK_CONTRACT_ADDRESS,
sorobanTx,
MOCK_CREDENTIAL_ID,
signFn
)
).resolves.toBeDefined();
});

it('throws SignatureExpiredException when signatureExpirationLedger has already passed', async () => {
const authEntry = makeAuthEntry(CURRENT_LEDGER - 1);
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.signWithSessionKey(
MOCK_CONTRACT_ADDRESS,
sorobanTx,
MOCK_CREDENTIAL_ID,
signFn
)
).rejects.toThrow(SignatureExpiredException);
});

it('throws SignatureExpiredException when nearly expired (within buffer)', async () => {
const authEntry = makeAuthEntry(CURRENT_LEDGER + BUFFER); // at boundary
mockServer.simulateTransaction.mockResolvedValue(
makeSimResult(authEntry)
);

await expect(
service.signWithSessionKey(
MOCK_CONTRACT_ADDRESS,
sorobanTx,
MOCK_CREDENTIAL_ID,
signFn
)
).rejects.toThrow(SignatureExpiredException);
});
});
});

// =========================================================================
// deploy() with autoTrustlineUSDC
// =========================================================================
Expand Down
16 changes: 16 additions & 0 deletions packages/core/wallet/src/types/smart-wallet.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
/**
* Thrown when a Soroban auth entry's signatureExpirationLedger is at or within
* the configured expiration buffer of the current ledger sequence.
*/
export class SignatureExpiredException extends Error {
constructor(
public readonly expirationLedger: number,
public readonly currentLedger: number
) {
super(
`Signature expires at ledger ${expirationLedger}, which is at or within the expiration buffer of the current ledger ${currentLedger}.`
);
this.name = 'SignatureExpiredException';
}
}

export interface CredentialBackend {
get(options: CredentialRequestOptions): Promise<PublicKeyCredential | null>;
create?(
Expand Down
Loading