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
26 changes: 3 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"lint:fix": "eslint src packages/sdk/src --fix",
"format": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"pretest": "npm run build:sdk",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
Expand Down
41 changes: 41 additions & 0 deletions src/agent/tools/agentic-wallet/__tests__/security-pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from "vitest";
import Database from "better-sqlite3";
import { migrateAgenticWallet } from "../schema.js";
import { setPin, verifyPin } from "../security.js";

describe("wallet PIN verify — atomic failed_attempts", () => {
let db: Database.Database;

beforeEach(() => {
db = new Database(":memory:");
migrateAgenticWallet(db);
setPin(db, 1, "4242");
});

it("increments failed_attempts on each wrong PIN without losing updates", () => {
for (let i = 1; i <= 4; i++) {
expect(() => verifyPin(db, 1, "0000")).toThrow(/Wrong PIN/);
}
const row = db
.prepare("SELECT failed_attempts, locked_until FROM wallet_pins WHERE user_id = 1")
.get() as { failed_attempts: number; locked_until: number };
expect(row.failed_attempts).toBe(4);
expect(row.locked_until).toBe(0);

expect(() => verifyPin(db, 1, "0000")).toThrow(/locked/i);
const locked = db
.prepare("SELECT failed_attempts, locked_until FROM wallet_pins WHERE user_id = 1")
.get() as { failed_attempts: number; locked_until: number };
expect(locked.failed_attempts).toBeGreaterThanOrEqual(5);
expect(locked.locked_until).toBeGreaterThan(0);
});

it("resets counter on success after failures", () => {
expect(() => verifyPin(db, 1, "0000")).toThrow(/Wrong PIN/);
expect(verifyPin(db, 1, "4242")).toBe(true);
const row = db.prepare("SELECT failed_attempts FROM wallet_pins WHERE user_id = 1").get() as {
failed_attempts: number;
};
expect(row.failed_attempts).toBe(0);
});
});
45 changes: 30 additions & 15 deletions src/agent/tools/agentic-wallet/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,27 +92,42 @@ export function verifyPin(db: Database.Database, userId: number, pin: string): b
return true;
}

// Wrong PIN
const newAttempts = row.failed_attempts + 1;
if (newAttempts >= MAX_PIN_ATTEMPTS) {
const lockedUntil = now + LOCKOUT_DURATION_SEC;
db.prepare(
"UPDATE wallet_pins SET failed_attempts = ?, locked_until = ? WHERE user_id = ?"
).run(newAttempts, lockedUntil, userId);
auditLog(db, userId, "pin_lockout", `Locked after ${newAttempts} failed attempts`);
log.warn({ userId, attempts: newAttempts }, "Account locked — too many failed PIN attempts");
// Wrong PIN — one atomic UPDATE (increment + optional lockout) so concurrent
// callers cannot lose increments between read and write.
const lockUntil = now + LOCKOUT_DURATION_SEC;
const after = db
.prepare(
`UPDATE wallet_pins
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
WHEN failed_attempts + 1 >= ? THEN ?
ELSE locked_until
END
WHERE user_id = ?
RETURNING failed_attempts, locked_until`
)
.get(MAX_PIN_ATTEMPTS, lockUntil, userId) as
| { failed_attempts: number; locked_until: number }
| undefined;

if (!after) {
throw new Error("PIN state update failed (user row missing).");
}

if (after.failed_attempts >= MAX_PIN_ATTEMPTS) {
auditLog(db, userId, "pin_lockout", `Locked after ${after.failed_attempts} failed attempts`);
log.warn(
{ userId, attempts: after.failed_attempts },
"Account locked — too many failed PIN attempts"
);
throw new Error(
`Too many failed attempts. Account locked for ${LOCKOUT_DURATION_SEC / 60} minutes.`
);
}

db.prepare("UPDATE wallet_pins SET failed_attempts = ? WHERE user_id = ?").run(
newAttempts,
userId
);
auditLog(db, userId, "pin_failed", `Failed attempt ${newAttempts}/${MAX_PIN_ATTEMPTS}`);
auditLog(db, userId, "pin_failed", `Failed attempt ${after.failed_attempts}/${MAX_PIN_ATTEMPTS}`);

const remaining = MAX_PIN_ATTEMPTS - newAttempts;
const remaining = MAX_PIN_ATTEMPTS - after.failed_attempts;
throw new Error(
`Wrong PIN. ${remaining} attempt${remaining > 1 ? "s" : ""} remaining before lockout.`
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";

import { createMarketAppAdapter } from "../adapters/marketapp-adapter.js";

describe("Market.app adapter token scoping", () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it("does not leak Authorization header across concurrent adapter instances", async () => {
const seenAuth: string[] = [];

const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (auth) seenAuth.push(auth);
return { ok: true, json: async () => [] } as unknown as Response;
}) as unknown as Mock;

vi.stubGlobal("fetch", fetchMock);

const a = createMarketAppAdapter("token-A");
const b = createMarketAppAdapter("token-B");

const [okA, okB] = await Promise.all([a.isAvailable(), b.isAvailable()]);
expect(okA).toBe(true);
expect(okB).toBe(true);

expect(seenAuth).toContain("token-A");
expect(seenAuth).toContain("token-B");
expect(seenAuth.filter((t) => t === "token-A").length).toBe(1);
expect(seenAuth.filter((t) => t === "token-B").length).toBe(1);
});
});
Loading
Loading