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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Writes need a wallet sign-in when `AUTH_REQUIRED=true` (the default in productio

Events must follow the lifecycle: `commissioning → shipping → receiving → storing → selling`, with `shipping` allowed again after receiving or storing. A sold item is final. GTINs are checked against the GS1 check digit, and a token ID or GTIN + serial can only be registered once.

## Clone detection

Each lookup is scored for the pattern a photocopied label leaves: many different networks scanning one code, or scans in bursts. The product page shows a *Scan risk* meter and flags a likely clone. Try it on the demo item with *Simulate a cloned label*. Details in [backend/README.md](backend/README.md#scan-anomaly-detection).

## Repository layout

| Folder | What it is |
Expand Down
9 changes: 9 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ Set `RPC_URL`, `NFT_CONTRACT_ADDRESS`, `REGISTRY_CONTRACT_ADDRESS` and `RELAYER_

The relayer is a trust assumption: the API decides who may do what, and the contracts enforce ordering and revocation. Try it end to end against a local node with `npm run smoke:chain` (starts Hardhat, deploys, runs a full product life, checks the contracts directly).

## Scan anomaly detection

Every product lookup counts as a scan. The API logs, per token and for the last 24 hours, which *network* each scan came from (the /24 of the IP, hashed with a server secret; addresses are never stored) and scores the pattern in `services/risk.ts`:

- **Spread:** a genuine label is read in a few places, a photocopied one shows up everywhere. Distinct networks above 4 (above 2 once the item is sold) raise the score.
- **Bursts:** more than 8 scans inside one minute looks like a script probing the code.

`score = 100 * (1 - exp(-(0.28 * spread + 0.12 * probing)))`, with 35 and 70 as the medium and high cut-offs. Lookups return `risk: { score, level, reasons, scans, sources }` and the web app shows it on the product page, flagging a likely clone. It is a transparent statistical model, not a trained network, so every reason traces back to a number. The history is in memory (with several API instances, move it to Redis). The in-browser demo mirrors the same function (`frontend/src/lib/risk.ts`); on the demo item, press *Simulate a cloned label*.

## Logs

One JSON line per request (`id`, `method`, `path`, `status`, `ms`, `user`); the id is also returned as `X-Request-Id`. Send your own `X-Request-Id` to trace a call across services.
Expand Down
11 changes: 11 additions & 0 deletions backend/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ describe("open mode (local development)", () => {
expect((await api.get("/api/metadata/" + res.body.tokenId)).status).toBe(200);
});

it("scores scan risk on every lookup", async () => {
await api.post("/api/products").send(product());
const first = await api.get("/api/metadata/0xabc123");
expect(first.body.risk).toMatchObject({ level: "low", scans: 1, sources: 1 });
// a script hammering one product trips the burst check
let last = first;
for (let i = 0; i < 25; i++) last = await api.get("/api/metadata/0xabc123");
expect(last.body.risk.level).not.toBe("low");
expect(last.body.risk.reasons.join(" ")).toMatch(/within one minute/);
});

it("refuses to overwrite an existing token", async () => {
await api.post("/api/products").send(product());
const res = await api.post("/api/products").send(product({ name: "Fake" }));
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { verifyRouter } from "./routes/verify.js";
import { requestLog } from "./middleware/requestLog.js";
import { Anchor } from "./services/anchor.js";
import { NonceStore } from "./services/nonces.js";
import { ScanLog } from "./services/risk.js";
import type { Chain } from "./config/blockchain.js";

export function createApp(env: Env, repo: Repository, chain: Chain | null = null) {
Expand All @@ -28,6 +29,7 @@ export function createApp(env: Env, repo: Repository, chain: Chain | null = null
registry: chain?.registry ?? null,
anchor: chain?.write ? new Anchor(chain.write) : null,
nonces: new NonceStore(),
scans: new ScanLog(),
};

// behind nginx / a platform proxy, so client IPs are real for rate limiting
Expand Down
2 changes: 2 additions & 0 deletions backend/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Env } from "./config/env.js";
import type { Repository } from "./repo/types.js";
import type { Anchor } from "./services/anchor.js";
import type { NonceStore } from "./services/nonces.js";
import type { ScanLog } from "./services/risk.js";

export interface Deps {
env: Env;
Expand All @@ -15,6 +16,7 @@ export interface Deps {
/** Registry contract for reading stages. */
registry: Contract | null;
nonces: NonceStore;
scans: ScanLog;
}

/** Express 4 does not catch rejected promises from async handlers. */
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const metadataRouter = (deps: Deps) => {
"/:tokenId",
wrap(async (req, res) => {
const id = normalizeToken(req.params.tokenId);
const record = id && (await loadRecord(deps, id));
const record = id && (await loadRecord(deps, id, req));
if (!record) throw notFound("Product not found");
res.json(record);
}),
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const productsRouter = (deps: Deps) => {
"/:tokenId",
wrap(async (req, res) => {
const id = normalizeToken(req.params.tokenId);
const record = id && (await loadRecord(deps, id));
const record = id && (await loadRecord(deps, id, req));
if (!record) throw notFound("Product not found");
res.json(record);
}),
Expand Down
13 changes: 10 additions & 3 deletions backend/src/services/records.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { Request } from "express";
import type { Deps } from "../http.js";
import { readOnChainProduct } from "./blockchainService.js";
import { assessRisk, sourceOf } from "./risk.js";

/** Product, its events and (when a chain is configured) the on-chain view. Null when unknown. */
export async function loadRecord({ repo, nft, registry }: Deps, tokenId: string) {
/**
* Product, its events, the on-chain view (when a chain is configured) and a scan
* risk score. Looking a product up counts as a scan. Null when unknown.
*/
export async function loadRecord({ repo, nft, registry, scans, env }: Deps, tokenId: string, req?: Request) {
const product = await repo.getProduct(tokenId);
if (!product) return null;
if (req) scans.record(tokenId, sourceOf(req.ip, env.JWT_SECRET));
const [events, onChain] = await Promise.all([repo.listEvents(tokenId), readOnChainProduct(nft, tokenId, registry)]);
return { product, events, ...(onChain ? { onChain } : {}) };
const risk = assessRisk(scans.list(tokenId), product.status);
return { product, events, risk, ...(onChain ? { onChain } : {}) };
}
67 changes: 67 additions & 0 deletions backend/src/services/risk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { ScanLog, WINDOW_MS, assessRisk, sourceOf, type Scan } from "./risk.js";

const NOW = 1_700_000_000_000;
const spread = (n: number, gapMs = 120_000): Scan[] => Array.from({ length: n }, (_, i) => ({ t: NOW - i * gapMs, src: `net${i}` }));

describe("assessRisk", () => {
it("treats a few ordinary scans as low risk", () => {
const r = assessRisk(spread(3), "IN_TRANSIT", NOW);
expect(r).toMatchObject({ level: "low", score: 0, reasons: [] });
});

it("flags a code scanned from many networks, a cloned label's signature", () => {
const r = assessRisk(spread(10), "IN_TRANSIT", NOW);
expect(r.level).toBe("high");
expect(r.reasons[0]).toMatch(/10 different networks/);
});

it("is stricter once the item is sold", () => {
const unsold = assessRisk(spread(5), "RETAIL", NOW);
const sold = assessRisk(spread(5), "SOLD", NOW);
expect(sold.score).toBeGreaterThan(unsold.score);
expect(sold.level).toBe("medium");
expect(sold.reasons[0]).toMatch(/already been sold/);
});

it("flags scripted probing from a single source", () => {
const burst: Scan[] = Array.from({ length: 30 }, (_, i) => ({ t: NOW - i * 1000, src: "bot" }));
const r = assessRisk(burst, "RETAIL", NOW);
expect(r.level).toBe("high");
expect(r.sources).toBe(1);
expect(r.reasons.join(" ")).toMatch(/within one minute/);
});

it("ignores scans older than a day", () => {
const old: Scan[] = Array.from({ length: 20 }, (_, i) => ({ t: NOW - WINDOW_MS - 1000 - i, src: `n${i}` }));
expect(assessRisk(old, "SOLD", NOW)).toMatchObject({ level: "low", scans: 0 });
});

it("scores more spread as riskier, never lower", () => {
const scores = [2, 5, 8, 12, 20].map((n) => assessRisk(spread(n), "RETAIL", NOW).score);
expect([...scores].sort((a, b) => a - b)).toEqual(scores);
});
});

describe("sourceOf", () => {
it("groups one /24 together, separates others, and never exposes the address", () => {
const a = sourceOf("203.0.113.5", "secret-secret-secret");
expect(sourceOf("203.0.113.200", "secret-secret-secret")).toBe(a);
expect(sourceOf("203.0.114.5", "secret-secret-secret")).not.toBe(a);
expect(a).not.toContain("203");
expect(sourceOf("::ffff:203.0.113.9", "secret-secret-secret")).toBe(a);
});
});

describe("ScanLog", () => {
it("keeps a bounded rolling window per token", () => {
const log = new ScanLog(5, 2);
for (let i = 0; i < 9; i++) log.record("a", `s${i}`, NOW + i);
expect(log.list("a")).toHaveLength(5);
log.record("b", "x", NOW);
log.record("c", "x", NOW);
expect(log.list("a")).toHaveLength(0); // evicted: only 2 tokens are kept
log.record("c", "y", NOW + WINDOW_MS + 5);
expect(log.list("c")).toHaveLength(1); // the day-old scan dropped out
});
});
97 changes: 97 additions & 0 deletions backend/src/services/risk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createHmac } from "node:crypto";
import { isIP } from "node:net";

/**
* Scan anomaly detection. A genuine label is read by a handful of people in a
* handful of places; a cloned one shows up everywhere at once. Each lookup is
* logged against the token, then scored from how many distinct networks scanned
* it in the last day and how bursty the scanning is.
*
* This is a transparent statistical model (saturating score over two features),
* not a trained network: every reason it gives can be traced to a number.
* Keep it in sync with frontend/src/lib/risk.ts, which powers the demo.
*/

export const WINDOW_MS = 24 * 60 * 60 * 1000;
const BURST_MS = 60 * 1000;

export interface Scan {
t: number;
/** Opaque id of the network the scan came from. Never an IP address. */
src: string;
}

export type RiskLevel = "low" | "medium" | "high";

export interface Risk {
score: number;
level: RiskLevel;
reasons: string[];
scans: number;
sources: number;
}

export function assessRisk(scans: Scan[], status: string | undefined, now = Date.now()): Risk {
const recent = scans.filter((s) => now - s.t <= WINDOW_MS).sort((a, b) => a.t - b.t);
const sources = new Set(recent.map((s) => s.src)).size;

// most scans inside any one-minute window
let burst = 0;
for (let i = 0, j = 0; i < recent.length; i++) {
while (recent[i].t - recent[j].t > BURST_MS) j++;
burst = Math.max(burst, i - j + 1);
}

// unsold stock passes through many hands; a sold item is checked by its buyer and few others
const sold = status === "SOLD";
const spread = Math.max(0, sources - (sold ? 2 : 4));
const probing = Math.max(0, burst - 8);

const score = Math.round(100 * (1 - Math.exp(-(0.28 * spread + 0.12 * probing))));
const level: RiskLevel = score >= 70 ? "high" : score >= 35 ? "medium" : "low";

const reasons: string[] = [];
if (spread > 0) {
reasons.push(
`${sources} different networks scanned this code in 24 hours${sold ? ", but it has already been sold" : ""}.`,
);
}
if (probing > 0) reasons.push(`${burst} scans landed within one minute, which looks automated.`);

return { score, level, reasons, scans: recent.length, sources };
}

/** Coarse network id: the /24 for IPv4 (so one office is one source), the /64 for IPv6, hashed with a secret. */
export function sourceOf(ip: string | undefined, secret: string): string {
const addr = (ip ?? "unknown").replace(/^::ffff:/, "");
let net = addr;
if (isIP(addr) === 4) net = addr.split(".").slice(0, 3).join(".");
else if (isIP(addr) === 6) net = addr.split(":").slice(0, 4).join(":");
return createHmac("sha256", secret).update(net).digest("hex").slice(0, 12);
}

/**
* Rolling per-token scan history, kept in memory (a day of signal does not
* need to survive a restart). With several API instances, back it with Redis.
*/
export class ScanLog {
private byToken = new Map<string, Scan[]>();

constructor(
private readonly maxPerToken = 500,
private readonly maxTokens = 10_000,
) {}

record(tokenId: string, src: string, now = Date.now()) {
const list = (this.byToken.get(tokenId) ?? []).filter((s) => now - s.t <= WINDOW_MS);
list.push({ t: now, src });
if (list.length > this.maxPerToken) list.splice(0, list.length - this.maxPerToken);
this.byToken.delete(tokenId); // re-insert so the oldest tokens are evicted first
this.byToken.set(tokenId, list);
if (this.byToken.size > this.maxTokens) this.byToken.delete(this.byToken.keys().next().value as string);
}

list(tokenId: string): Scan[] {
return this.byToken.get(tokenId) ?? [];
}
}
34 changes: 34 additions & 0 deletions contracts/test/Gas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { GTIN, deployProtocol } from "./helpers.js";

// Measures what each on-chain action costs and fails if one gets noticeably more expensive.
// Run `npm test` and read the table to quote real numbers.
describe("gas costs", () => {
it("stays within budget for the whole product life", async () => {
const { viem, nft, registry, maker, shipper, shop } = await deployProtocol();
const client = await viem.getPublicClient();
const used = async (hash: `0x${string}`) => Number((await client.getTransactionReceipt({ hash })).gasUsed);

const id = await nft.read.tokenIdFor([GTIN, "GAS-1"]);
const rows: Array<[string, number, number]> = [];
const check = async (label: string, hash: `0x${string}`, budget: number) => {
const gas = await used(hash);
rows.push([label, gas, budget]);
assert.ok(gas <= budget, label + " used " + gas + " gas, budget " + budget);
};

await check("mint (ERC-721 + metadata)", await nft.write.mintProduct([maker.account.address, GTIN, "GAS-1", 7n], { account: maker.account }), 215_000);
await check("stage 1: Minted", await registry.write.advance([id], { account: maker.account }), 70_000);
await check("stage 2: InDistribution", await registry.write.advance([id], { account: shipper.account }), 52_000);
await check("stage 3: AtRetail", await registry.write.advance([id], { account: shop.account }), 52_000);
await check("stage 4: ConsumerOwned", await registry.write.advance([id], { account: shop.account }), 52_000);
await check("revoke", await nft.write.revokeProduct([id, "recall"], { account: maker.account }), 44_000);

const total = rows.slice(0, 5).reduce((n, r) => n + r[1], 0);
console.log("\n action gas");
for (const [label, gas] of rows) console.log(" " + label.padEnd(30) + String(gas).padStart(8));
console.log(" " + "full life (mint + 4 stages)".padEnd(30) + String(total).padStart(8) + "\n");
});
});
5 changes: 5 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ AuthenTick is a hybrid system. Ownership and authenticity are anchored on a publ
- **GTIN validation.** `domain/gtin.ts` checks the GS1 check digit.
- **Wallet auth.** The server issues a stateless, HMAC-signed challenge. The wallet signs it, the server recovers the address, looks up the role, and sets a 12 hour JWT in an `httpOnly` cookie (writes also need an anti-CSRF header). Each challenge is accepted once. Writes are role-checked when `AUTH_REQUIRED` is on.
- **On-chain anchoring.** With a relayer key configured, minting and each lifecycle event are mirrored to `AuthenTickNFT` and `OwnershipRegistry` before they are recorded off-chain, so the contracts have the final say on duplicates, ordering and revocation. See [backend/README.md](../backend/README.md#on-chain-anchoring).
- **Scan anomaly detection.** Lookups are scored for clone-like patterns (many networks, or bursts) and the result is returned with the record. See [backend/README.md](../backend/README.md#scan-anomaly-detection).
- **Ownership proofs.** A per-product challenge is signed by the claimed owner. The server checks the signature and compares the address with the registered owner, or with `ownerOf` on-chain when `RPC_URL` and `NFT_CONTRACT_ADDRESS` are set.

## Frontend (`frontend/`)
Expand All @@ -37,6 +38,10 @@ React 19 + Vite. If the API cannot be reached, the app switches to an in-browser
3. The record is shown with its provenance timeline and current status.
4. To prove ownership, the wallet signs a challenge and the API checks the signature against the registered owner.

## Gas costs (measured)

`contracts/test/Gas.ts` measures each action and fails if one gets more expensive: mint about 196k gas, the four stage changes about 64k, 47k, 47k and 47k, revoke about 40k. A product's whole on-chain life is about 400k gas. Only coarse stages go on-chain; EPCIS events stay in the database.

## GS1 EPCIS

Events follow the EPCIS who/what/when/where/why model: product (GTIN + serial), event time, read point (GLN), and business step (`urn:epcglobal:cbv:bizstep:*`).
Expand Down
14 changes: 14 additions & 0 deletions frontend/e2e/journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,17 @@ test("an unknown token says so instead of hanging", async ({ page }) => {
await page.goto("/product/0xdoesnotexist");
await expect(page.getByText(/can.t verify this product/i).first()).toBeVisible();
});

test("a cloned label is flagged as suspicious, and restarting clears it", async ({ page }) => {
await page.goto(`/product/${DEMO_TOKEN}`);
await expect(page.getByText("Scan risk")).toBeVisible();
await expect(page.getByText("Normal", { exact: true })).toBeVisible();

await page.getByRole("button", { name: "Simulate a cloned label" }).click();
await expect(page.getByText("Suspicious", { exact: true })).toBeVisible();
await expect(page.getByText(/different networks scanned this code/)).toBeVisible();
await expect(page.getByText("Possible clone: check details")).toBeVisible();

await page.getByRole("button", { name: "Restart journey" }).click();
await expect(page.getByText("Normal", { exact: true })).toBeVisible();
});
17 changes: 16 additions & 1 deletion frontend/src/lib/mockApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEMO_TOKEN, isDemoToken, isValidGtin14, mockRequest, resetDemoToken, resetMock } from "./mockApi";
import { DEMO_TOKEN, isDemoToken, isValidGtin14, mockRequest, resetDemoToken, resetMock, simulateClonedScans } from "./mockApi";

// the mock waits a few hundred ms to feel real; tests do not need that
vi.stubGlobal("setTimeout", ((fn: () => void) => {
Expand Down Expand Up @@ -63,6 +63,21 @@ describe("demo token", () => {
});
});

describe("scan risk", () => {
it("starts normal, turns suspicious after a cloned-label attack, and resets with the journey", async () => {
const first = (await get(DEMO_TOKEN)) as unknown as { risk: { level: string; sources: number } };
expect(first.risk).toMatchObject({ level: "low", sources: 1 });

simulateClonedScans(DEMO_TOKEN);
const after = (await get(DEMO_TOKEN)) as unknown as { risk: { level: string; reasons: string[] } };
expect(after.risk.level).toBe("high");
expect(after.risk.reasons[0]).toMatch(/different networks/);

resetDemoToken();
expect(((await get(DEMO_TOKEN)) as unknown as { risk: { level: string } }).risk.level).toBe("low");
});
});

describe("token ids", () => {
it("are derived from gtin + serial when the form leaves them blank", async () => {
const a = (await mint({ tokenId: undefined, serial: "S-1" })) as { tokenId: string };
Expand Down
Loading
Loading