Some checks failed
JavaScript SDK Tests / test-js-sdk (push) Failing after 9s
package-tests / test-python-packages (map[name:aitbc-agent-sdk path:packages/py/aitbc-agent-sdk python_version:3.13]) (push) Failing after 10s
package-tests / test-python-packages (map[name:aitbc-core path:packages/py/aitbc-core python_version:3.13]) (push) Failing after 10s
integration-tests / test-service-integration (push) Has been cancelled
package-tests / test-python-packages (map[name:aitbc-crypto path:packages/py/aitbc-crypto python_version:3.13]) (push) Has been cancelled
package-tests / test-javascript-packages (map[name:aitbc-sdk node_version:24 path:packages/js/aitbc-sdk]) (push) Has been cancelled
package-tests / test-python-packages (map[name:aitbc-sdk path:packages/py/aitbc-sdk python_version:3.13]) (push) Has been cancelled
package-tests / cross-language-compatibility (push) Has been cancelled
package-tests / package-integration-tests (push) Has been cancelled
security-scanning / audit (push) Has been cancelled
210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
import { verify as verifySignature, createPublicKey } from "crypto";
|
|
import type { ReceiptListResponse, ReceiptSummary, RequestOptions } from "./types";
|
|
import { AitbcClient } from "./client";
|
|
|
|
export interface PaginatedReceipts {
|
|
items: ReceiptSummary[];
|
|
nextCursor?: string | null;
|
|
}
|
|
|
|
export interface ReceiptValidationResult {
|
|
valid: boolean;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface ReceiptValidationOptions extends RequestOptions {
|
|
minerVerifier?: (receipt: ReceiptSummary) => Promise<boolean> | boolean;
|
|
coordinatorVerifier?: (receipt: ReceiptSummary) => Promise<boolean> | boolean;
|
|
minerPublicKeyPem?: string;
|
|
coordinatorPublicKeyPem?: string;
|
|
signatureAlgorithm?: "ed25519" | "secp256k1" | "rsa" | string;
|
|
}
|
|
|
|
export interface ReceiptServiceOptions {
|
|
client: AitbcClient;
|
|
maxRetries?: number;
|
|
retryDelayMs?: number;
|
|
minerPublicKeyPem?: string;
|
|
coordinatorPublicKeyPem?: string;
|
|
signatureAlgorithm?: "ed25519" | "secp256k1" | "rsa" | string;
|
|
}
|
|
|
|
export class ReceiptService {
|
|
private client: AitbcClient;
|
|
private maxRetries: number;
|
|
private retryDelayMs: number;
|
|
private minerPublicKeyPem?: string;
|
|
private coordinatorPublicKeyPem?: string;
|
|
private signatureAlgorithm?: string;
|
|
|
|
constructor(opts: ReceiptServiceOptions) {
|
|
this.client = opts.client;
|
|
this.maxRetries = opts.maxRetries ?? 3;
|
|
this.retryDelayMs = opts.retryDelayMs ?? 250;
|
|
this.minerPublicKeyPem = opts.minerPublicKeyPem;
|
|
this.coordinatorPublicKeyPem = opts.coordinatorPublicKeyPem;
|
|
this.signatureAlgorithm = opts.signatureAlgorithm ?? "ed25519";
|
|
}
|
|
|
|
async getJobReceipts(
|
|
jobId: string,
|
|
cursor?: string,
|
|
limit: number = 50,
|
|
options?: RequestOptions,
|
|
): Promise<PaginatedReceipts> {
|
|
let attempt = 0;
|
|
let lastError: unknown;
|
|
|
|
while (attempt <= this.maxRetries) {
|
|
try {
|
|
const data = await this.client.request<ReceiptListResponse & { next_cursor?: string }>(
|
|
"GET",
|
|
`/v1/jobs/${jobId}/receipts`,
|
|
{
|
|
...options,
|
|
query: {
|
|
cursor,
|
|
limit,
|
|
},
|
|
}
|
|
);
|
|
return {
|
|
items: (data.items ?? []).filter((r) => !r.jobId || r.jobId === jobId),
|
|
nextCursor: data.next_cursor ?? (data as any).nextCursor ?? null,
|
|
};
|
|
} catch (err) {
|
|
lastError = err;
|
|
attempt += 1;
|
|
if (attempt > this.maxRetries) break;
|
|
await this.delay(this.retryDelayMs * attempt);
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error("Failed to fetch receipts");
|
|
}
|
|
|
|
async validateReceipt(receipt: ReceiptSummary, options?: ReceiptValidationOptions): Promise<ReceiptValidationResult> {
|
|
// Placeholder for full cryptographic verification: delegate to coordinator API
|
|
const {
|
|
minerVerifier,
|
|
coordinatorVerifier,
|
|
minerPublicKeyPem,
|
|
coordinatorPublicKeyPem,
|
|
signatureAlgorithm,
|
|
...requestOptions
|
|
} = options ?? {};
|
|
try {
|
|
const data = await this.client.request<{ valid: boolean; reason?: string }>(
|
|
"POST",
|
|
"/v1/receipts/verify",
|
|
{
|
|
...requestOptions,
|
|
body: JSON.stringify(this.buildVerificationPayload(receipt)),
|
|
}
|
|
);
|
|
return { valid: !!data.valid, reason: data.reason };
|
|
} catch (err) {
|
|
// Fallback to local checks if API unavailable
|
|
const local = await this.validateLocally(
|
|
receipt,
|
|
minerVerifier,
|
|
coordinatorVerifier,
|
|
minerPublicKeyPem ?? this.minerPublicKeyPem,
|
|
coordinatorPublicKeyPem ?? this.coordinatorPublicKeyPem,
|
|
signatureAlgorithm ?? this.signatureAlgorithm ?? "ed25519"
|
|
);
|
|
return local.valid ? local : { valid: false, reason: (err as Error).message };
|
|
}
|
|
}
|
|
|
|
buildVerificationPayload(receipt: ReceiptSummary) {
|
|
return {
|
|
receipt_id: receipt.receiptId,
|
|
job_id: receipt.jobId,
|
|
miner: receipt.miner,
|
|
coordinator: receipt.coordinator,
|
|
issued_at: receipt.issuedAt,
|
|
status: receipt.status,
|
|
payload: receipt.payload,
|
|
};
|
|
}
|
|
|
|
async validateLocally(
|
|
receipt: ReceiptSummary,
|
|
minerVerifier?: (receipt: ReceiptSummary) => Promise<boolean> | boolean,
|
|
coordinatorVerifier?: (receipt: ReceiptSummary) => Promise<boolean> | boolean,
|
|
minerPublicKeyPem?: string,
|
|
coordinatorPublicKeyPem?: string,
|
|
signatureAlgorithm: string = "ed25519",
|
|
): Promise<ReceiptValidationResult> {
|
|
const payload = receipt.payload ?? {};
|
|
const sig = payload.signature ?? {};
|
|
const minerSig = payload.minerSignature ?? sig.sig;
|
|
const coordinatorSig = payload.coordinatorSignature ?? sig.sig;
|
|
|
|
if (!minerSig) {
|
|
return { valid: false, reason: "missing miner signature" };
|
|
}
|
|
if (!coordinatorSig) {
|
|
return { valid: false, reason: "missing coordinator signature" };
|
|
}
|
|
if (!payload.job_id && receipt.jobId) {
|
|
return { valid: false, reason: "missing job_id in payload" };
|
|
}
|
|
|
|
const payloadForSig = this.sanitizePayload(payload);
|
|
|
|
if (minerVerifier) {
|
|
const ok = await minerVerifier(receipt);
|
|
if (!ok) return { valid: false, reason: "miner signature invalid" };
|
|
} else if (minerPublicKeyPem) {
|
|
const ok = this.verifyWithCrypto(minerSig, minerPublicKeyPem, payloadForSig, signatureAlgorithm);
|
|
if (!ok) return { valid: false, reason: "miner signature invalid" };
|
|
}
|
|
if (coordinatorVerifier) {
|
|
const ok = await coordinatorVerifier(receipt);
|
|
if (!ok) return { valid: false, reason: "coordinator signature invalid" };
|
|
} else if (coordinatorPublicKeyPem) {
|
|
const ok = this.verifyWithCrypto(coordinatorSig, coordinatorPublicKeyPem, payloadForSig, signatureAlgorithm);
|
|
if (!ok) return { valid: false, reason: "coordinator signature invalid" };
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
private sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const { _signature, _minerSignature, _coordinatorSignature, ...rest } = payload ?? {};
|
|
return rest;
|
|
}
|
|
|
|
private verifyWithCrypto(
|
|
signatureBase64: string,
|
|
publicKeyPem: string,
|
|
payload: Record<string, unknown>,
|
|
alg: string,
|
|
): boolean {
|
|
try {
|
|
const key = createPublicKey(publicKeyPem);
|
|
const message = Buffer.from(JSON.stringify(payload ?? {}));
|
|
const sig = Buffer.from(signatureBase64, "base64");
|
|
|
|
if (alg.toLowerCase() === "ed25519") {
|
|
return verifySignature(null, message, key, sig);
|
|
}
|
|
if (alg.toLowerCase() === "secp256k1") {
|
|
return verifySignature("sha256", message, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
}
|
|
if (alg.toLowerCase().startsWith("rsa")) {
|
|
return verifySignature("sha256", message, key, sig);
|
|
}
|
|
// Unknown alg: fail closed
|
|
return false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
}
|