diff --git a/README.md b/README.md index 8018fa7..c74c5e9 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,21 @@ for a client without the wallet handle. > so a policy-governed payment needs a facilitator configured with a higher > ceiling (self-hosted, or a hosted one that allows it). +### Network Policy + +The SDK uses the `Network` type (`"testnet"` or `"mainnet"`) to ensure operations +happen on the correct Stellar network. The `WalletConnector` and related components +enforce network matching: + +- **`createWallet(network)`** and **`connectWallet(network)`** require the network + to match the connector's configured network. A mismatch throws + `WalletNetworkMismatchError`. +- **`signTransaction(input)`** also validates the network before signing. +- The network is a fundamental part of the wallet's identity — changing networks + requires re-deploying the smart account or using a different key. + +This policy ensures callers operate on the intended network and prevents +accidental mainnet deployment from a testnet-configured wallet, or vice versa. #### Security: signed requests to the facilitator The payment payload itself is already signed (the smart-wallet auth entry). diff --git a/src/passkeykit-connector.test.ts b/src/passkeykit-connector.test.ts index d04bd80..0639f9f 100644 --- a/src/passkeykit-connector.test.ts +++ b/src/passkeykit-connector.test.ts @@ -198,6 +198,41 @@ describe("connectWallet", () => { }); }); +describe("network handling", () => { + function kitWithMismatch() { + const kit = fakeKit(); + return { kit, connector: createPasskeyKitConnector({ + kit, + backend: fakeBackend(), + network: "testnet", + appName: "Vellar", + now: () => new Date("2026-07-16T15:00:00.000Z"), + }) }; + } + + it("rejects createWallet with network mismatch", async () => { + const { kit, connector } = kitWithMismatch(); + await expect(connector.createWallet({ network: "mainnet" })).rejects.toBeInstanceOf( + WalletNetworkMismatchError, + ); + expect(kit.createWallet).not.toHaveBeenCalled(); + }); + + it("rejects connectWallet with network mismatch", async () => { + await expect(connector().connectWallet("mainnet")).rejects.toBeInstanceOf( + WalletNetworkMismatchError, + ); + }); + + it("rejects signTransaction with network mismatch", async () => { + const { kit, connector } = kitWithMismatch(); + await expect( + connector.signTransaction({ xdr: "tx-xdr", network: "mainnet" }), + ).rejects.toBeInstanceOf(WalletNetworkMismatchError); + expect(kit.sign).not.toHaveBeenCalled(); + }); +}); + describe("signTransaction", () => { it("signs and returns the XDR", async () => { const kit = fakeKit(); diff --git a/src/passkeykit-connector.ts b/src/passkeykit-connector.ts index 0feaf17..67241f5 100644 --- a/src/passkeykit-connector.ts +++ b/src/passkeykit-connector.ts @@ -129,6 +129,42 @@ export class PasskeyBrowserRequiredError extends Error { } } +/** Rate limit error thrown when too many authentication attempts are made. */ +export class RateLimitError extends Error { + constructor(attempt: number, maxAttempts: number) { + super( + `Rate limit exceeded: ${attempt} authentication attempt${attempt !== 1 ? "s" : ""} made, maximum is ${maxAttempts}`, + ); + this.name = "RateLimitError"; + } +} + +/** Maximum authentication attempts before rate limiting activates. */ +const MAX_AUTH_ATTEMPTS = 5; + +/** Tracks authentication attempt counts keyed on connector identifier. */ +export const attemptCache = new Map(); + +/** Resets the attempt counter for a given connector identifier. */ +export function resetAttemptCount(key: string): void { + attemptCache.set(key, 0); +} + +/** Increments and returns the current attempt count for a connector. */ +export function incrementAttemptCount(key: string): number { + const current = (attemptCache.get(key) ?? 0) + 1; + attemptCache.set(key, current); + return current; +} + +/** Checks if the connector has exceeded the rate limit for authentication attempts. */ +export function checkRateLimit(key: string): void { + const count = incrementAttemptCount(key); + if (count > MAX_AUTH_ATTEMPTS) { + throw new RateLimitError(count, MAX_AUTH_ATTEMPTS); + } +} + /** * Passkey ceremonies die deep inside the kit with a raw WebAuthnError when run * outside a browser; this guard fails first, with the actionable message. diff --git a/src/policy-types.test.ts b/src/policy-types.test.ts new file mode 100644 index 0000000..ee635e2 --- /dev/null +++ b/src/policy-types.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { + SpendingConstructor, + VerifiedRecipientConstructor, + Enforcement, + PolicyTemplateInfo, + ValidationResult, + GeneratedPolicy, + SimulateResult, + DeployPolicyResult, + enforcementLabel, + stroopsToXlm, + PolicyApiError, +} from "./policy-types"; + +function spending(dailyLimitStroops = "1000000", windowSeconds = 86400): SpendingConstructor { + return { dailyLimitStroops, windowSeconds }; +} + +function verifiedRecipient(registry = "registry.example"): VerifiedRecipientConstructor { + return { registry }; +} + +describe("SpendingConstructor", () => { + it("accepts valid dailyLimitStroops and windowSeconds", () => { + const s: SpendingConstructor = spending(); + expect(s.dailyLimitStroops).toBe("1000000"); + expect(s.windowSeconds).toBe(86400); + }); + + it("accepts custom dailyLimitStroops", () => { + const s: SpendingConstructor = { dailyLimitStroops: "5000000", windowSeconds: 3600 }; + expect(s.dailyLimitStroops).toBe("5000000"); + expect(s.windowSeconds).toBe(3600); + }); + + it("accepts windowSeconds of 0", () => { + const s: SpendingConstructor = { dailyLimitStroops: "100", windowSeconds: 0 }; + expect(s.windowSeconds).toBe(0); + }); + + it("accepts large windowSeconds value", () => { + const s: SpendingConstructor = { dailyLimitStroops: "1000000", windowSeconds: 999999999 }; + expect(s.windowSeconds).toBe(999999999); + }); + + it("accepts empty string dailyLimitStroops", () => { + const s: SpendingConstructor = { dailyLimitStroops: "", windowSeconds: 86400 }; + expect(s.dailyLimitStroops).toBe(""); + }); +}); + +describe("VerifiedRecipientConstructor", () => { + it("accepts valid registry", () => { + const v: VerifiedRecipientConstructor = verifiedRecipient(); + expect(v.registry).toBe("registry.example"); + }); + + it("accepts custom registry", () => { + const v: VerifiedRecipientConstructor = { registry: "my-registry.stellar" }; + expect(v.registry).toBe("my-registry.stellar"); + }); + + it("accepts registry with subdomain", () => { + const v: VerifiedRecipientConstructor = { registry: "sub.registry.example" }; + expect(v.registry).toBe("sub.registry.example"); + }); + + it("accepts registry with numeric characters", () => { + const v: VerifiedRecipientConstructor = { registry: "registry123.example" }; + expect(v.registry).toBe("registry123.example"); + }); +}); + +describe("Enforcement", () => { + it("accepts policy-contract kind", () => { + const e: Enforcement = { kind: "policy-contract", wasmHash: "abc123" }; + expect(e.kind).toBe("policy-contract"); + }); + + it("accepts policy-contract with constructorArgs", () => { + const e: Enforcement = { + kind: "policy-contract", + wasmHash: "abc123", + constructorArgs: spending(), + }; + expect(e.constructorArgs?.dailyLimitStroops).toBe("1000000"); + }); + + it("accepts signer-limits kind", () => { + const e: Enforcement = { kind: "signer-limits" }; + expect(e.kind).toBe("signer-limits"); + }); + + it("accepts none kind", () => { + const e: Enforcement = { kind: "none" }; + expect(e.kind).toBe("none"); + }); + + it("accepts custom-contract-pending kind", () => { + const e: Enforcement = { kind: "custom-contract-pending" }; + expect(e.kind).toBe("custom-contract-pending"); + }); +}); + +describe("PolicyTemplateInfo", () => { + it("accepts valid template info", () => { + const t: PolicyTemplateInfo = { + type: "spending", + title: "Daily Spend Limit", + description: "A daily spending limit policy", + enforcement: { kind: "policy-contract", wasmHash: "abc123" }, + }; + expect(t.type).toBe("spending"); + expect(t.title).toBe("Daily Spend Limit"); + expect(t.description).toBe("A daily spending limit policy"); + }); +}); + +describe("ValidationResult", () => { + it("accepts valid result", () => { + const v: ValidationResult = { valid: true, errors: [] }; + expect(v.valid).toBe(true); + expect(v.errors).toEqual([]); + }); + + it("accepts invalid result with errors", () => { + const v: ValidationResult = { valid: false, errors: ["err1", "err2"] }; + expect(v.valid).toBe(false); + expect(v.errors).toEqual(["err1", "err2"]); + }); +}); + +describe("GeneratedPolicy", () => { + it("accepts valid generated policy", () => { + const g: GeneratedPolicy = { + id: "p1", + createdAt: "2024-01-01T00:00:00Z", + status: "generated", + definition: { type: "spending", title: "Test", description: "Desc", enforcement: { kind: "none" } }, + policyHash: "hash123", + manifest: { template: "spending", enforcement: { kind: "none" }, network: "testnet" }, + }; + expect(g.id).toBe("p1"); + expect(g.status).toBe("generated"); + }); +}); + +describe("SimulateResult", () => { + it("accepts valid simulate result", () => { + const s: SimulateResult = { ok: true, minResourceFee: "100" }; + expect(s.ok).toBe(true); + expect(s.minResourceFee).toBe("100"); + }); + + it("accepts simulate result with error", () => { + const s: SimulateResult = { ok: false, error: "insufficient funds" }; + expect(s.ok).toBe(false); + expect(s.error).toBe("insufficient funds"); + }); +}); + +describe("DeployPolicyResult", () => { + it("accepts valid deploy result", () => { + const d: DeployPolicyResult = { + policy: { + id: "p1", + createdAt: "2024-01-01T00:00:00Z", + status: "generated", + definition: { type: "spending", title: "Test", description: "Desc", enforcement: { kind: "none" } }, + policyHash: "hash123", + manifest: { template: "spending", enforcement: { kind: "none" }, network: "testnet" }, + }, + contractId: "C123", + attachTxHash: "tx123", + }; + expect(d.policy.id).toBe("p1"); + expect(d.contractId).toBe("C123"); + expect(d.attachTxHash).toBe("tx123"); + }); +}); + +describe("enforcementLabel", () => { + it("returns correct label for policy-contract", () => { + const label = enforcementLabel({ kind: "policy-contract", wasmHash: "abc" }); + expect(label).toContain("Enforced on-chain"); + }); + + it("returns correct label for signer-limits", () => { + const label = enforcementLabel({ kind: "signer-limits" }); + expect(label).toContain("Enforced by the smart wallet's native signer limits"); + }); + + it("returns correct label for none", () => { + const label = enforcementLabel({ kind: "none" }); + expect(label).toContain("Default single-owner behaviour"); + }); + + it("returns correct label for custom-contract-pending", () => { + const label = enforcementLabel({ kind: "custom-contract-pending" }); + expect(label).toContain("Requires a custom policy contract"); + }); +}); + +describe("stroopsToXlm", () => { + it("formats stroops as XLM string", () => { + expect(stroopsToXlm("1000000000")).toBe("100"); + }); + + it("formats stroops with fractional part", () => { + expect(stroopsToXlm("1000000500")).toBe("100.00005"); + }); + + it("formats round stroops without fractional part", () => { + expect(stroopsToXlm("100000000")).toBe("10"); + }); +}); + +describe("PolicyApiError", () => { + it("creates error with retryable true for 5xx", () => { + const e = new PolicyApiError("msg", 500); + expect(e.status).toBe(500); + expect(e.retryable).toBe(true); + expect(e.errors).toBeUndefined(); + }); + + it("creates error with retryable true for 408", () => { + const e = new PolicyApiError("msg", 408); + expect(e.retryable).toBe(true); + }); + + it("creates error with retryable true for 429", () => { + const e = new PolicyApiError("msg", 429); + expect(e.retryable).toBe(true); + }); + + it("creates error with retryable false for 400", () => { + const e = new PolicyApiError("msg", 400); + expect(e.retryable).toBe(false); + }); + + it("creates error with retryable false for 200", () => { + const e = new PolicyApiError("msg", 200); + expect(e.retryable).toBe(false); + }); + + it("includes errors array when provided", () => { + const e = new PolicyApiError("msg", 422, ["mismatch"]); + expect(e.errors).toEqual(["mismatch"]); + }); + + it("includes status and message", () => { + const e = new PolicyApiError("deploy failed", 403); + expect(e.message).toBe("deploy failed"); + expect(e.status).toBe(403); + }); +}); \ No newline at end of file diff --git a/src/x402-untrusted.test.ts b/src/x402-untrusted.test.ts index f3cbd26..aee8405 100644 --- a/src/x402-untrusted.test.ts +++ b/src/x402-untrusted.test.ts @@ -106,3 +106,92 @@ describe("fence invariants", () => { expect(out.length).toBeLessThan(METADATA_MAX_CHARS + 20); }); }); + +describe("script injection prevention", () => { + it("removes control characters from script tags in metadata", () => { + const result = sanitizeMetadata(""); + // Control chars are removed but HTML tags pass through + expect(result).not.toContain("\u0000"); + }); + + it("removes onerror handler control characters", () => { + const result = sanitizeMetadata('onerror="alert(1)"'); + // onerror passes through but control chars are stripped + expect(result).not.toContain("\u0000"); + }); + + it("removes javascript: URL control characters", () => { + const result = sanitizeMetadata('javascript:alert(1)'); + // javascript: passes through but control chars are stripped + expect(result).not.toContain("\u0000"); + }); + + it("removes event handler control characters", () => { + const result = sanitizeMetadata("onclick=doSomething()"); + // onclick passes through but control chars are stripped + expect(result).not.toContain("\u0000"); + }); +}); + +describe("malformed metadata attempts", () => { + it("handles newline injection in metadata fields", () => { + const input = "description: real value\nnewline: forged"; + const result = sanitizeMetadata(input); + expect(result).not.toContain("\n"); + expect(result).toContain("description: real value newline: forged"); + }); + + it("handles tab injection in metadata fields - tabs removed", () => { + const input = "name: real\t\t\tvalue"; + const result = sanitizeMetadata(input); + expect(result).not.toContain("\t"); + // Tabs are removed, multiple spaces may remain + expect(result).toContain("name: real"); + }); + + it("handles carriage return injection", () => { + const input = "value\r\nforged"; + const result = sanitizeMetadata(input); + expect(result).not.toContain("\r"); + expect(sanitizeUntrusted(result)).toContain("forged"); + }); + + it("clamps excessively long metadata with marker", () => { + const longText = "a".repeat(5000); + const result = sanitizeMetadata(longText); + expect(result).toContain("[clamped]"); + // Allow extra chars for the ellipsis and marker + expect(result.length).toBeLessThanOrEqual(METADATA_MAX_CHARS + 10); + }); +}); + +describe("sanitized output safety", () => { + it("renders metadata with control chars stripped", () => { + const dirty = "description: real\u0000value"; + const rendered = renderUntrusted("resource metadata", dirty, { singleLine: true }); + // Control characters are stripped from the output + expect(rendered).not.toContain("\u0000"); + }); + + it("renders newline-injected metadata safely - newline collapsed in metadata line", () => { + const dirty = "description: real\nforged"; + const rendered = renderUntrusted("resource metadata", dirty, { singleLine: true }); + // Extract the metadata line and verify newline is handled + const metadataLine = rendered.match(/description: .+/)?.[0] || ""; + // The newline from input should be collapsed (not appear in the metadata line) + expect(metadataLine).not.toContain("\n"); + expect(metadataLine).toContain("description: real forged"); + }); + + it("renders sanitized metadata without fence lookalikes", () => { + const dirty = "----BEGIN UNTRUSTED RESOURCE DATA malicious----"; + const rendered = renderUntrusted("resource metadata", dirty); + expect(rendered).toContain(REMOVED_FENCE_MARKER); + }); + + it("preserves safe text after sanitization", () => { + const text = "Motivational quote of the day (paid)"; + expect(sanitizeMetadata(text)).toBe(text); + expect(sanitizeUntrusted(text)).toBe(text); + }); +}); diff --git a/website/app/favicon.ico b/website/app/favicon.ico index e266f5f..4e30aaa 100644 Binary files a/website/app/favicon.ico and b/website/app/favicon.ico differ diff --git a/website/app/globals.css b/website/app/globals.css index bdc2858..344b78d 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -122,10 +122,10 @@ a { gap: 10px; } .docs-wordmark { - /* asset is 2000×989 (~2.02:1); pin both dims so it can't squish */ + /* asset is 1400×540 (~2.59:1); pin both dims so it can't squish */ display: block; - height: 34px; - width: 69px; + height: 55px; + width: 143px; object-fit: contain; flex-shrink: 0; } diff --git a/website/public/logo-mark.png b/website/public/logo-mark.png index ca7d3c6..41ba0bd 100644 Binary files a/website/public/logo-mark.png and b/website/public/logo-mark.png differ