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: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ ORACLE_RELAYER_SECRET=
# Grant the runtime IAM role access to this secret; never put the signing key
# in an environment variable. Use a dedicated, low-value testnet key.
ORACLE_RELAYER_SECRET_ID=
# Dedicated Ed25519 seed used to sign oracle decision attestations. Generate
# with `openssl rand -base64 32`; keep it separate from the Stellar relayer key.
ORACLE_ATTESTATION_PRIVATE_KEY=
# Multi-key relayer keystore: a JSON file (mode 0600, mounted from a secret
# manager) with a "keys" array of { id, secret, network, active } entries.
# Re-read when it changes, so rotation/revocation needs no restart. Takes
Expand Down Expand Up @@ -474,4 +477,3 @@ ORACLE_RELAYER_SECRET_ID=
# HORIZON_FINALITY_DEPTH=5
# final_only (default) | allow_provisional
# HORIZON_FINALITY_POLICY=final_only

412 changes: 218 additions & 194 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"refract-frontend",
"refract-contracts"
],
"main": "dist/main.js",
"scripts": {
"build": "nest build",
"dev": "nodemon --exec ts-node src/main.ts",
Expand Down Expand Up @@ -86,6 +87,7 @@
"@types/node": "^20.10.0",
"@types/pg": "^8.10.9",
"@types/supertest": "^7.2.0",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
Expand Down
33 changes: 33 additions & 0 deletions scripts/verify-oracle-attestation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { createHash, createPublicKey, verify } from "node:crypto";
import { readFile } from "node:fs/promises";

const [tokenPath, publicKeyBase64Url] = process.argv.slice(2);
if (!tokenPath || !publicKeyBase64Url) {
console.error("Usage: node scripts/verify-oracle-attestation.mjs <attestation.json> <public-key-base64url>");
process.exit(2);
}

const { attestation } = JSON.parse(await readFile(tokenPath, "utf8"));
const [version, encodedPayload, encodedSignature, extra] = String(attestation).split(".");
if (version !== "oa1" || !encodedPayload || !encodedSignature || extra !== undefined) {
throw new Error("Invalid compact oracle attestation format");
}

const publicBytes = Buffer.from(publicKeyBase64Url, "base64url");
if (publicBytes.length !== 32) throw new Error("The Ed25519 public key must decode to 32 bytes");
const keyId = createHash("sha256").update(publicKeyBase64Url).digest("hex").slice(0, 16);
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
if (payload.version !== 1 || payload.keyId !== keyId) {
throw new Error("Attestation key ID does not match the supplied verification key");
}

const key = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), publicBytes]),
format: "der",
type: "spki",
});
if (!verify(null, Buffer.from(encodedPayload), key, Buffer.from(encodedSignature, "base64url"))) {
throw new Error("Oracle attestation signature is invalid");
}
console.log(JSON.stringify({ verified: true, payload }, null, 2));
15 changes: 15 additions & 0 deletions src/oracle/oracle-attestation.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Controller, Get } from "@nestjs/common";
import { OracleAttestationService } from "./oracle-attestation.service";

@Controller("api/v1/oracle")
export class OracleAttestationController {
constructor(private readonly attestations: OracleAttestationService) {}

@Get("verification-keys")
getVerificationKeys() {
return {
version: 1,
keys: this.attestations.getVerificationKeys(),
};
}
}
60 changes: 60 additions & 0 deletions src/oracle/oracle-attestation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createHash, createPublicKey, verify } from "node:crypto";
import {
decodeOracleAttestation,
OracleAttestationService,
} from "./oracle-attestation.service";
import { OracleReading } from "./oracle-reading";

const reading: OracleReading = {
coverageType: "StablecoinDepeg",
type: "oracle_update",
value: 0.99,
threshold: 0.95,
severity: "low",
message: "USDC price [CoinGecko]",
};

describe("OracleAttestationService", () => {
it("signs a compact decision with its evidence root and public verification key", () => {
const service = new OracleAttestationService(Buffer.alloc(32, 9).toString("base64"));
const input = {
adapter: "coingecko",
schemaVersion: 1,
digest: "a".repeat(64),
};
const { token, evidenceRoot } = service.createAttestation(reading, [input], "2026-10-20T00:00:00.000Z");
const [prefix, encodedPayload, encodedSignature] = token.split(".");
const payload = decodeOracleAttestation(token);
const publicKey = service.getVerificationKeys()[0];
const publicBytes = Buffer.from(publicKey.publicKey, "base64url");
const keyId = createHash("sha256").update(publicKey.publicKey).digest("hex").slice(0, 16);
const verificationKey = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), publicBytes]),
format: "der",
type: "spki",
});

expect(prefix).toBe("oa1");
expect(payload.feed).toBe("StablecoinDepeg");
expect(payload.inputs).toEqual([input]);
expect(payload.quorum).toEqual({ required: 1, observed: 1, outcome: "met" });
expect(payload.evidenceRoot).toBe(evidenceRoot);
expect(payload.timestamp).toBe("2026-10-20T00:00:00.000Z");
expect(payload.keyId).toBe(keyId);
expect(verify(null, Buffer.from(encodedPayload), verificationKey, Buffer.from(encodedSignature, "base64url"))).toBe(true);
});

it("signs fail-safe readings with a not-met quorum and rejects missing signing keys", () => {
const service = new OracleAttestationService(Buffer.alloc(32, 3).toString("base64"));
const result = service.createAttestation(reading, [], "2026-10-20T00:00:00.000Z");

expect(decodeOracleAttestation(result.token).quorum).toEqual({
required: 1,
observed: 0,
outcome: "not_met",
});
expect(() => new OracleAttestationService().createAttestation(reading, [])).toThrow(
"ORACLE_ATTESTATION_PRIVATE_KEY",
);
});
});
140 changes: 140 additions & 0 deletions src/oracle/oracle-attestation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Injectable } from "@nestjs/common";
import { createHash, createPrivateKey, createPublicKey, sign } from "node:crypto";
import { OracleReading } from "./oracle-reading";

const PRIVATE_KEY_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");

export interface AttestationInput {
adapter: string;
schemaVersion: number;
digest: string;
eligible?: boolean;
}

export interface OracleDecisionPayload {
version: 1;
keyId: string;
feed: string;
decision: {
value: number;
threshold: number;
severity: OracleReading["severity"];
};
inputs: AttestationInput[];
quorum: {
required: number;
observed: number;
outcome: "met" | "not_met";
};
evidenceRoot: string;
timestamp: string;
}

export interface OracleVerificationKey {
keyId: string;
algorithm: "Ed25519";
publicKey: string;
}

function base64Url(value: Buffer): string {
return value.toString("base64url");
}

function canonicalJson(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
}
return JSON.stringify(value);
}

function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}

@Injectable()
export class OracleAttestationService {
private readonly privateKey: ReturnType<typeof createPrivateKey> | null;
private readonly verificationKey: OracleVerificationKey | null;

constructor(seed = process.env.ORACLE_ATTESTATION_PRIVATE_KEY?.trim() ?? "") {
if (!seed) {
this.privateKey = null;
this.verificationKey = null;
return;
}

const seedBytes = Buffer.from(seed, "base64");
if (seedBytes.length !== 32 || seedBytes.toString("base64") !== seed) {
throw new Error("ORACLE_ATTESTATION_PRIVATE_KEY must be a base64-encoded 32-byte Ed25519 seed");
}

this.privateKey = createPrivateKey({
key: Buffer.concat([PRIVATE_KEY_PREFIX, seedBytes]),
format: "der",
type: "pkcs8",
});
const publicDer = createPublicKey(this.privateKey).export({ format: "der", type: "spki" });
const publicBytes = Buffer.from(publicDer).subarray(-32);
this.verificationKey = {
keyId: sha256(publicBytes.toString("base64url")).slice(0, 16),
algorithm: "Ed25519",
publicKey: base64Url(publicBytes),
};
}

getVerificationKeys(): OracleVerificationKey[] {
return this.verificationKey ? [this.verificationKey] : [];
}

createAttestation(
reading: OracleReading,
inputs: AttestationInput[],
timestamp = new Date().toISOString(),
): { token: string; evidenceRoot: string } {
if (!this.privateKey || !this.verificationKey) {
throw new Error("Oracle attestations are not configured; set ORACLE_ATTESTATION_PRIVATE_KEY");
}

const orderedInputs = [...inputs].sort((left, right) =>
`${left.adapter}:${left.digest}`.localeCompare(`${right.adapter}:${right.digest}`),
);
const evidenceRoot = sha256(canonicalJson(orderedInputs));
const payload: OracleDecisionPayload = {
version: 1,
keyId: this.verificationKey.keyId,
feed: reading.coverageType,
decision: {
value: reading.value,
threshold: reading.threshold,
severity: reading.severity,
},
inputs: orderedInputs,
quorum: {
required: 1,
observed: orderedInputs.filter((input) => input.eligible !== false).length,
outcome: orderedInputs.some((input) => input.eligible !== false) ? "met" : "not_met",
},
evidenceRoot,
timestamp,
};
const encodedPayload = base64Url(Buffer.from(canonicalJson(payload)));
const signature = base64Url(sign(null, Buffer.from(encodedPayload), this.privateKey));
return { token: `oa1.${encodedPayload}.${signature}`, evidenceRoot };
}
}

export function decodeOracleAttestation(token: string): OracleDecisionPayload {
const [version, encodedPayload, signature, extra] = token.split(".");
if (version !== "oa1" || !encodedPayload || !signature || extra !== undefined) {
throw new Error("Invalid compact oracle attestation format");
}
const payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")) as OracleDecisionPayload;
if (payload.version !== 1 || payload.keyId.length === 0) {
throw new Error("Unsupported oracle attestation payload");
}
return payload;
}
8 changes: 8 additions & 0 deletions src/oracle/oracle-reading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { FreshnessRejectionReason } from "./freshnessPolicy";

export type Severity = "low" | "medium" | "high" | "triggered";

export interface OracleEvidenceMetadata {
adapter: string;
schemaVersion: number;
digest: string;
attribution: string;
sensitivity: "public" | "commercial" | "personal" | "synthetic";
}

export interface OracleReading {
readingId?: string;
coverageType: string;
Expand Down
65 changes: 65 additions & 0 deletions src/oracle/oracle-source-adapters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
captureProviderPayload,
PROVIDER_SCHEMA_DEPRECATION_DAYS,
} from "./oracle-source-adapters";

describe("captureProviderPayload", () => {
it("accepts additive provider fields by default and hashes only the normalized payload", () => {
const captured = captureProviderPayload("coingecko", {
"usd-coin": { usd: 0.99, provider_extension: "ignored" },
request_id: "not-retained",
});

expect(captured.payload).toEqual({ "usd-coin": { usd: 0.99 } });
expect(captured.adapter).toBe("coingecko");
expect(captured.schemaVersion).toBe(1);
expect(captured.attribution).toBe("CoinGecko");
expect(captured.policy.rawPayloadStorage).toBe("transient_only");
expect(captured.digest).toMatch(/^[a-f0-9]{64}$/);
});

it("rejects unexpected fields in strict mode, including nested flight identifiers", () => {
expect(() =>
captureProviderPayload(
"aviationstack",
{ flight: { iata: "BA249", delay: 150, passenger_name: "must-not-be-kept" } },
{ strict: true },
),
).toThrow();
});

it("strips passenger-identifying fields and prohibits retaining flight payloads", () => {
const captured = captureProviderPayload("aviationstack", {
flight: { iata: "BA249", delay: 150, passenger_name: "must-not-be-kept" },
passenger: { email: "must-not-be-kept@example.test" },
});

expect(captured.payload).toEqual({ flight: { iata: "BA249", delay: 150 } });
expect(captured.policy).toMatchObject({
sensitivity: "personal",
rawPayloadStorage: "prohibited",
publicApiPayload: "derived_only",
});
expect(JSON.stringify(captured)).not.toContain("must-not-be-kept");
});

it("normalizes legacy response envelopes during the 90-day deprecation window only", () => {
const start = Date.parse("2026-10-20T00:00:00.000Z");
const insideWindow = start + (PROVIDER_SCHEMA_DEPRECATION_DAYS - 1) * 86_400_000;
const captured = captureProviderPayload(
"coingecko",
{ data: { "usd-coin": { usd: 1 } } },
{ schemaVersion: 0, now: insideWindow },
);
expect(captured.schemaVersion).toBe(0);
expect(captured.payload).toEqual({ "usd-coin": { usd: 1 } });

expect(() =>
captureProviderPayload(
"coingecko",
{ data: { "usd-coin": { usd: 1 } } },
{ schemaVersion: 0, now: start + (PROVIDER_SCHEMA_DEPRECATION_DAYS + 1) * 86_400_000 },
),
).toThrow("passed its deprecation window");
});
});
Loading