From 6f322d1c757ae65c286c18e57d22a105ce52cbb5 Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:28:21 +0000 Subject: [PATCH] feat: add signed meter metadata payloads (#547) - Add MeterMetadata type and computeMetadataHash() to crypto lib - Extend ReadingSchema to accept optional metadata + metadata_signature_hex - Verify Ed25519 metadata signature in POST /api/readings - Persist metadata, metadata_hash, metadata_signature_hex on readings row - Add migration 003 for new readings columns - Update send-reading.mjs to sign and send --firmware/--model/--manufacturer --- apps/web/src/app/api/readings/route.ts | 38 ++++++++++++++++++++++++-- apps/web/src/lib/crypto.ts | 22 +++++++++++++++ apps/web/src/lib/database.types.ts | 3 ++ docs/migrations/003_meter_metadata.sql | 7 +++++ scripts/send-reading.mjs | 36 ++++++++++++++++++++---- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 docs/migrations/003_meter_metadata.sql diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index 87d6761..940ca4c 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -3,15 +3,29 @@ import { verify } from '@noble/ed25519' import { z } from 'zod' import { createServiceClient } from '@/lib/supabase' import { anchorReading, mintCertificates } from '@/lib/stellar' -import { computeReadingHash } from '@/lib/crypto' +import { computeReadingHash, computeMetadataHash, MeterMetadata } from '@/lib/crypto' import { kwhToStroops } from '@solarproof/stellar' +const MetadataSchema = z.object({ + firmware_version: z.string().optional(), + hardware_model: z.string().optional(), + location_lat: z.number().optional(), + location_lon: z.number().optional(), + manufacturer: z.string().optional(), +}) + const ReadingSchema = z.object({ meter_id: z.string().uuid(), kwh: z.number().positive(), timestamp: z.number().int().positive(), // Unix seconds signature_hex: z.string().length(128), // 64-byte Ed25519 sig as hex -}) + // Optional signed metadata payload + metadata: MetadataSchema.optional(), + metadata_signature_hex: z.string().length(128).optional(), // Ed25519 sig over metadata hash +}).refine( + (d) => !d.metadata || !!d.metadata_signature_hex, + { message: 'metadata_signature_hex required when metadata is present', path: ['metadata_signature_hex'] } +) /** * POST /api/readings @@ -28,7 +42,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) } - const { meter_id, kwh, timestamp, signature_hex } = parsed.data + const { meter_id, kwh, timestamp, signature_hex, metadata, metadata_signature_hex } = parsed.data const db = createServiceClient() // Fetch meter + cooperative @@ -58,6 +72,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid meter signature' }, { status: 401 }) } + // Verify optional metadata signature + let metadataHash: Buffer | null = null + if (metadata && metadata_signature_hex) { + metadataHash = computeMetadataHash(metadata as MeterMetadata) + const metaSigValid = await verify( + Buffer.from(metadata_signature_hex, 'hex'), + metadataHash, + Buffer.from(meter.pubkey_hex, 'hex') + ).catch(() => false) + + if (!metaSigValid) { + return NextResponse.json({ error: 'Invalid metadata signature' }, { status: 401 }) + } + } + // Persist reading const { data: reading, error: readingErr } = await db .from('readings') @@ -69,6 +98,9 @@ export async function POST(req: NextRequest) { signature_hex, anchored: false, minted: false, + metadata: metadata ?? null, + metadata_hash: metadataHash ? metadataHash.toString('hex') : null, + metadata_signature_hex: metadata_signature_hex ?? null, }) .select() .single() diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index e6b2f7b..505a933 100644 --- a/apps/web/src/lib/crypto.ts +++ b/apps/web/src/lib/crypto.ts @@ -12,3 +12,25 @@ export function computeReadingHash(meterId: string, kwhStroops: bigint, timestam tsBuf.writeBigInt64LE(timestampUnix) return createHash('sha256').update(meterBytes).update(kwhBuf).update(tsBuf).digest() } + +/** + * Meter metadata that can accompany a signed reading. + * All fields are optional — the meter may not expose all of them. + */ +export interface MeterMetadata { + firmware_version?: string + hardware_model?: string + location_lat?: number + location_lon?: number + manufacturer?: string +} + +/** + * Compute the canonical metadata hash: SHA-256(canonical JSON of metadata). + * The meter signs this hash with its Ed25519 key so the payload is tamper-evident. + */ +export function computeMetadataHash(metadata: MeterMetadata): Buffer { + // Sort keys for deterministic serialisation + const canonical = JSON.stringify(metadata, Object.keys(metadata).sort()) + return createHash('sha256').update(Buffer.from(canonical, 'utf8')).digest() +} diff --git a/apps/web/src/lib/database.types.ts b/apps/web/src/lib/database.types.ts index 74f6439..75b279f 100644 --- a/apps/web/src/lib/database.types.ts +++ b/apps/web/src/lib/database.types.ts @@ -22,6 +22,9 @@ export interface Database { reading_hash: string; signature_hex: string anchor_tx_hash: string | null; mint_tx_hash: string | null anchored: boolean; minted: boolean + metadata: Record | null + metadata_hash: string | null // SHA-256 hex of canonical metadata JSON + metadata_signature_hex: string | null // Ed25519 sig over metadata_hash (128 hex chars) } Insert: Omit Update: Partial diff --git a/docs/migrations/003_meter_metadata.sql b/docs/migrations/003_meter_metadata.sql new file mode 100644 index 0000000..f48bfb2 --- /dev/null +++ b/docs/migrations/003_meter_metadata.sql @@ -0,0 +1,7 @@ +-- Migration 003: add signed meter metadata columns to readings +alter table readings + add column if not exists metadata jsonb, + add column if not exists metadata_hash text, -- SHA-256 hex of canonical metadata JSON + add column if not exists metadata_signature_hex text; -- Ed25519 sig over metadata_hash (128 hex chars) + +create index if not exists readings_metadata_hash_idx on readings(metadata_hash); diff --git a/scripts/send-reading.mjs b/scripts/send-reading.mjs index 025ff50..3d71434 100644 --- a/scripts/send-reading.mjs +++ b/scripts/send-reading.mjs @@ -2,14 +2,17 @@ /** * scripts/send-reading.mjs * - * Simulate a smart meter sending a signed reading to the SolarProof API. + * Simulate a smart meter sending a signed reading (+ optional metadata) to the SolarProof API. * * Usage: * node scripts/send-reading.mjs \ * --meter-id \ * --kwh 12.5 \ * --key ./meter-key.json \ - * --api http://localhost:3000 + * --api http://localhost:3000 \ + * [--firmware 1.2.3] \ + * [--model "SolarEdge-SE7600H"] \ + * [--manufacturer "SolarEdge"] */ import { createSign, createHash } from 'crypto' @@ -22,6 +25,9 @@ const meterId = get('--meter-id') ?? 'test-meter-id' const kwh = parseFloat(get('--kwh') ?? '10') const keyFile = get('--key') ?? './meter-key.json' const api = get('--api') ?? 'http://localhost:3000' +const firmware = get('--firmware') +const model = get('--model') +const manufacturer = get('--manufacturer') const { private_key_hex } = JSON.parse(readFileSync(keyFile, 'utf8')) const timestamp = Math.floor(Date.now() / 1000) @@ -38,9 +44,14 @@ const privKeyDer = Buffer.concat([ Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.from(private_key_hex, 'hex'), ]) -const sign = createSign('ed25519') -sign.update(readingHash) -const signature = sign.sign({ key: privKeyDer, format: 'der', type: 'pkcs8' }) + +function signHash(hash) { + const sign = createSign('ed25519') + sign.update(hash) + return sign.sign({ key: privKeyDer, format: 'der', type: 'pkcs8' }) +} + +const signature = signHash(readingHash) const body = { meter_id: meterId, @@ -49,6 +60,21 @@ const body = { signature_hex: signature.toString('hex'), } +// Attach optional signed metadata +const metadata = {} +if (firmware) metadata.firmware_version = firmware +if (model) metadata.hardware_model = model +if (manufacturer) metadata.manufacturer = manufacturer + +if (Object.keys(metadata).length > 0) { + const canonical = JSON.stringify(metadata, Object.keys(metadata).sort()) + const metadataHash = createHash('sha256').update(Buffer.from(canonical, 'utf8')).digest() + const metadataSig = signHash(metadataHash) + body.metadata = metadata + body.metadata_signature_hex = metadataSig.toString('hex') + console.log('Metadata hash:', metadataHash.toString('hex')) +} + console.log('Sending reading:', { meterId, kwh, timestamp }) console.log('Reading hash:', readingHash.toString('hex'))