Skip to main content

Validating a cookpit cooking file in your app

Audience: developers building any app that consumes cookpit v3.2 cooking files (.cpt.A.jsonld). Status: canonical reference. The operational implementation lives at apps/functions/cookpit/appFileValidation.ts and is reachable over HTTP at POST /api/validate-file. This document is the published, language- neutral version of that same algorithm, with the public key and a JavaScript translation you can drop into your own server.


1. Why your app is the last line of defence

An authenticated file served from a trusted origin — say cookchow.com/recipes/3.2/... over TLS — is fine while it stays there. The problem is that anyone can author a file, store it anywhere, and move it by any means: email, a USB stick, a re-host, a chat attachment. The moment the file leaves the trusted origin, none of the things you were relying on travel with it:

  • the origin ("I got it from cookchow.com") — gone once it's copied;
  • TLS — protects the hop, not the file at rest;
  • the .A. filename — anyone can name a file anything;
  • the fileFingerprint (a SHA-256) — anyone can recompute it.

Exactly one property travels with the bytes and cannot be forged: the Ed25519 signature made with cookpit's private key. So for a file of unknown provenance, the signature is not an extra layer — it is the entire basis for trust. Your app is the last place that check can happen before the file is executed, and an unsafe plan can do physical harm in a kitchen. Verify before you cook.


2. The one rule

A cookpit file is trusted if, and only if, its Ed25519 signature verifies against cookpit's pinned public key.

The fileFingerprint, the status: "authenticated" string, and the filename are never sufficient on their own. Do not "verify" by re-hashing. Do not trust a key named by the file. Check the signature against the key you pinned.

Why the fingerprint is not enough. Suppose an attacker edits a quantity — say a temperature or a timing — in a genuine file. They can recompute the SHA-256 and overwrite fileFingerprint so it matches again. The fingerprint check now passes. But the signature was made over the original bytes with cookpit's private key, which the attacker does not have. When you re-derive the bytes and check the signature, it fails — the edit is exposed. An app that checks only the fingerprint waves the tampered file straight through. (This is proven by a conformance test; see §8.)

Fail closed. If the signature does not verify, refuse to use the file for anything. No "use it anyway" override.


3. Pinned constants

FieldPinned value
Issuerhttps://validator.cookpit.org/v3.2
CanonicalizationRFC8785 (JSON Canonicalization Scheme)
keyIdcookpit-chefs-friend
Signature alg.Ed25519 (RFC 8032)

These are checked against your pinned copies — never against whatever the file declares. The keyId in the file is matched equal to the pinned id; the key bytes you actually verify with come from §4, not from the file or its supplier.


4. The cookpit public key (publish & pin this)

Publishing the public key is safe by design. Asymmetric signatures mean the public key can verify a signature but cannot create one. The private key never leaves cookpit's secret mount and is never published. So embedding the public key in your app gives an attacker nothing.

SubjectPublicKeyInfo (SPKI), PEM:

-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAlpp7FCHACvmw5u3FuKbHPBaTVpxAC1l3gGi1adg81Xo=
-----END PUBLIC KEY-----
  • keyId: cookpit-chefs-friend
  • Algorithm: Ed25519 (RFC 8032)
  • SPKI SHA-256 (out-of-band check): 4063db5fb83de0cc361781244bece424947d783f6841840e79ce828411931730

Pin it, don't fetch-and-trust. Embed this key as a constant in your app (as the reference code below does). If you must fetch it, fetch over TLS once and verify its SPKI SHA-256 against the value above before using it. The trust anchor must not be swappable by whoever ships you a file.

Rotation / revocation. There is currently one key and no rotation channel. Do not build automatic key-fetching against a URL. A future key change will be published and versioned deliberately, not pushed silently.


5. The algorithm, step by step

Given a parsed file object:

  1. Shape. It must be a JSON object with a cookpit.attestation object. → otherwise MALFORMED / NOT_AUTHENTICATED.
  2. status must equal "authenticated". → otherwise NOT_AUTHENTICATED.
  3. Every authenticated field (issuer, validatorVersion, issuedAt, canonicalization, keyId, fileFingerprint, signature) must be a non-empty string. → otherwise MALFORMED.
  4. Pin checks: issuer == pinned issuer, canonicalization == RFC8785, keyId == cookpit-chefs-friend. → UNKNOWN_ISSUER, UNSUPPORTED_CANONICALIZATION, UNKNOWN_KEY.
  5. Canonicalize the signed bytes. Deep-clone the file, set both signature and fileFingerprint to the empty string "", then serialize with RFC 8785 (JCS). (Blanking both fields is load-bearing — the signed bytes exclude them. This is the step most re-implementations get wrong.)
  6. Fingerprint compare — integrity only. SHA-256 of the canonical bytes, lower-case hex, compared to the declared fileFingerprint. A mismatch means the file was altered (FINGERPRINT_MISMATCH). This is a helpful diagnosis, not the trust decision.
  7. The gate — verify the signature. Base64-decode signature, then Ed25519-verify it over the canonical bytes (not over the hash) using the pinned public key. Failure → SIGNATURE_INVALID. Success → trusted.

Three things implementers routinely get wrong, all worth a comment in your code:

  • RFC 8785, not JSON.stringify. Plain serialization differs in key order, whitespace, number formatting and Unicode escaping, producing different bytes and a spurious failure.
  • Blank both signature and fileFingerprint before canonicalizing.
  • Ed25519 signs the bytes, not the digest. Do not feed the SHA-256 into the verifier.

6. Reference implementation (JavaScript, server-side)

Drop-in ESM module. Dependencies: Node's built-in crypto and the canonicalize package (RFC 8785). This is a faithful translation of the operational TypeScript module shipped in this repo (apps/functions/cookpit/appFileValidation.ts); both produce identical verdicts and are covered by the same conformance vectors.

// app_file_validation.js — self-contained cookpit v3.2 file verifier.
//
// THE ONE RULE: a file is trusted iff its Ed25519 signature verifies
// against the embedded cookpit public key. The fileFingerprint and the
// `status` string are NEVER sufficient on their own.

import { createHash, createPublicKey, verify } from 'node:crypto';
import canonicalize from 'canonicalize';

const PINNED_ISSUER = 'https://validator.cookpit.org/v3.2';
const PINNED_KEY_ID = 'cookpit-chefs-friend';
const SUPPORTED_CANONICALIZATION = 'RFC8785';

// Publishing the PUBLIC key is safe: it verifies but cannot sign.
// SPKI SHA-256: 4063db5fb83de0cc361781244bece424947d783f6841840e79ce828411931730
const COOKPIT_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAlpp7FCHACvmw5u3FuKbHPBaTVpxAC1l3gGi1adg81Xo=
-----END PUBLIC KEY-----
`;

// security-alert: presented as cookpit-signed but the crypto says altered.
// info: simply not a verifiable cookpit attestation.
const SECURITY_ALERT_CODES = new Set(['FINGERPRINT_MISMATCH', 'SIGNATURE_INVALID']);

const REASONS = {
MALFORMED: 'The file is not a JSON object, or its attestation block is missing or malformed.',
NOT_AUTHENTICATED: 'The file does not carry a cookpit "authenticated" attestation.',
UNKNOWN_ISSUER: 'The attestation issuer is not the pinned cookpit issuer.',
UNKNOWN_KEY: 'The attestation names a signing key other than the pinned cookpit key.',
UNSUPPORTED_CANONICALIZATION: 'The attestation uses an unsupported canonicalisation form.',
FINGERPRINT_MISMATCH: 'The file contents do not match the declared fingerprint — it was altered.',
SIGNATURE_INVALID: 'The Ed25519 signature did not verify — the file is not authentic.',
};

let cachedKey = null;
function cookpitPublicKey() {
if (!cachedKey) cachedKey = createPublicKey({ key: COOKPIT_PUBLIC_KEY_PEM, format: 'pem' });
return cachedKey;
}

const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);

function reject(code, fieldPath) {
return {
ok: false,
code,
severity: SECURITY_ALERT_CODES.has(code) ? 'security-alert' : 'info',
reason: REASONS[code],
...(fieldPath !== undefined ? { fieldPath } : {}),
};
}

/**
* Verify a parsed cookpit v3.2 file. FAIL CLOSED: use the file only when
* `result.ok === true`. Does not mutate `file`.
*/
export function verifyCookpitFile(file) {
if (!isObject(file)) return reject('MALFORMED', '/');
if (!isObject(file.cookpit)) return reject('MALFORMED', '/cookpit');
const att = file.cookpit.attestation;
if (!isObject(att)) return reject('NOT_AUTHENTICATED', '/cookpit/attestation');

if (typeof att.status !== 'string') return reject('MALFORMED', '/cookpit/attestation/status');
if (att.status !== 'authenticated')
return reject('NOT_AUTHENTICATED', '/cookpit/attestation/status');

for (const k of [
'issuer',
'validatorVersion',
'issuedAt',
'canonicalization',
'keyId',
'fileFingerprint',
'signature',
]) {
if (typeof att[k] !== 'string' || att[k].length === 0) {
return reject('MALFORMED', `/cookpit/attestation/${k}`);
}
}

// Pin checks — against OUR constants, never the file's choices.
if (att.issuer !== PINNED_ISSUER) return reject('UNKNOWN_ISSUER', '/cookpit/attestation/issuer');
if (att.canonicalization !== SUPPORTED_CANONICALIZATION)
return reject('UNSUPPORTED_CANONICALIZATION', '/cookpit/attestation/canonicalization');
if (att.keyId !== PINNED_KEY_ID) return reject('UNKNOWN_KEY', '/cookpit/attestation/keyId');

// Deep-clone, blank BOTH signature and fileFingerprint, canonicalize (RFC 8785).
let clone;
try {
clone = JSON.parse(JSON.stringify(file));
} catch {
return reject('MALFORMED', '/');
}
clone.cookpit.attestation.signature = '';
clone.cookpit.attestation.fileFingerprint = '';

const canonical = canonicalize(clone);
if (typeof canonical !== 'string') return reject('MALFORMED', '/');
const canonicalBytes = Buffer.from(canonical, 'utf8');

// Fingerprint — INTEGRITY ONLY, never the trust decision.
const computed = createHash('sha256').update(canonicalBytes).digest('hex');
if (computed !== att.fileFingerprint.toLowerCase())
return reject('FINGERPRINT_MISMATCH', '/cookpit/attestation/fileFingerprint');

// THE GATE — Ed25519 over the canonical bytes, pinned key.
let sigBytes;
try {
sigBytes = Buffer.from(att.signature, 'base64');
if (sigBytes.length === 0) throw new Error('empty');
} catch {
return reject('SIGNATURE_INVALID', '/cookpit/attestation/signature');
}

let sigOk = false;
try {
sigOk = verify(null, canonicalBytes, cookpitPublicKey(), sigBytes);
} catch {
sigOk = false;
}
if (!sigOk) return reject('SIGNATURE_INVALID', '/cookpit/attestation/signature');

return {
ok: true,
code: 'OK',
severity: 'ok',
signedBy: 'cookpit',
issuer: att.issuer,
validatorVersion: att.validatorVersion,
issuedAt: att.issuedAt,
keyId: att.keyId,
fileFingerprint: att.fileFingerprint,
};
}

Using it

import { verifyCookpitFile } from './app_file_validation.js';

const result = verifyCookpitFile(JSON.parse(fileText));
if (!result.ok) {
// Refuse. See §7 for how to respond by severity.
return rejectFile(result);
}
// result.ok === true → trusted provenance. Safe to load the plan.
// (Still bounds-check dangerous values — see §9.)

7. What to do on failure

Verification produces a discriminated verdict. Map it to behaviour by severity, not by guessing.

codeseverityMeaningApp response
OKokSignature verified against the pinned keyProceed.
SIGNATURE_INVALIDsecurity-alertCookpit-signed file whose signature fails — alteredSecurity alert.
FINGERPRINT_MISMATCHsecurity-alertBody doesn't match its fingerprint — alteredSecurity alert.
NOT_AUTHENTICATEDinfoUnsigned / not a cookpit attestationCalm rejection.
MALFORMEDinfoNot a cookpit file / corrupt JSONCalm rejection.
UNKNOWN_ISSUER / UNKNOWN_KEY / UNSUPPORTED_CANONICALIZATIONinfoSigned by something you don't pinCalm rejection.

On security-alert (tamper-evident failure): show a clear warning — "This file may have been altered and cannot be trusted. Do not cook from it; delete it and re-download from a trusted source." Then:

  • Fail closed. Do not load the plan. No override.
  • Quarantine, don't auto-delete. In a browser you usually can't delete the user's file, and auto-deletion destroys the evidence of an attack. Discard the parsed plan, persist/cache nothing, and advise the user to delete it.
  • Return to a safe state, don't self-terminate. The failure is scoped to one file; reset to the file picker. Killing the whole app punishes the user for a caught attack and hands an attacker an easy denial-of-service (a tampered file that crashes your app on demand). The security was won the moment you refused to execute — you don't need to shut down to prove it.

On info: a calm "this isn't a valid cookpit file" is enough. Don't fire the red alert on benign mistakes (wrong file, unsigned draft) — alert fatigue trains users to ignore the warning that matters.


8. Conformance vectors

Run your implementation against the published corpus before you trust it. All five files must verify (ok: true); each declared fileFingerprint is listed so you can confirm your canonicalization matches byte-for-byte. The corpus is published at cookpit.net — each filename below links to its canonical copy. (The filename is decorative and never part of the signed bytes, so re-downloading from this link and verifying it yourself is the point; don't trust the name.)

File (*.v3.2.cpt.A.jsonld)Expecteddeclared fileFingerprint
authentic-hungarian-goulash.v3.2.cpt.A.jsonldok8926a0169ee6f348f40ea77c65e0a95b6606a520643a26ca595579eb71cb7a80
perfect-boeuf-bourguignon.v3.2.cpt.A.jsonldok0acfeb1561839c088af308baa52b649af51d8b14e9188623dced5f8e9caae1fa
pork-fillet-braised-cheeks-and-pork-belly.v3.2.cpt.A.jsonldok2db2d949a3d0bff211a0e88c34c7ae0be404c4db17ff9b7dbbb4de6fa2c244a5
roast-chicken-with-cider-and-sage.v3.2.cpt.A.jsonldok2f93c50bc8200e5df315b5278b090b4928476008a9f8ffa32158374088683914
spaghetti-carbonara.v3.2.cpt.A.jsonldokc7025d043e283f4b4d0f3ea55f473db5c3a17d2b9f086dc9d371ca7f23af4439

Negative vectors your implementation must also satisfy:

  • Edit any field in a genuine file without touching the fingerprint → FINGERPRINT_MISMATCH.
  • Edit a quantity and recompute/reapply the fileFingerprint so it matches → must still be SIGNATURE_INVALID. (This is the headline attack — the signature exposes the change even though the fingerprint now matches.)
  • Replace keyId with a foreign key → UNKNOWN_KEY (not a tamper alert).

The corpus files are published at cookpit.net (https://cookpit.net/recipes/<slug>.v3.2.cpt.A.jsonld, linked above) and mirrored in the repo at corpus/attested/; the live test that asserts all of the above is apps/functions/tests/appFileValidation.test.ts.


9. What a pass means — and what it does not

A verifyCookpitFile(...) → ok: true proves provenance and integrity: these exact bytes were signed by cookpit and have not changed since. It is not a safety endorsement. A legitimately-signed file can still contain a value that is wrong for your kitchen.

So, regardless of verification, a robust cooking app should bounds-check or confirm dangerous values — temperatures, times — before acting on them. The signature protects the bytes; only your app protects the cook. When you surface the result to a user, prefer precise wording ("Signed by cookpit · key cookpit-chefs-friend · issued ") over a bare "Verified ✓" that reads as "safe".


10. Browser / offline verification (Web Crypto)

The server-side module above is the primary integration. The same check can also run entirely client-side and offline — no server round-trip — which is the ideal "last resort in the app" for files arriving by any means. Modern runtimes expose Ed25519 through the Web Crypto API (crypto.subtle), available in current browsers and in Node's webcrypto.

The algorithm is identical (§5); only the primitives change:

  • Canonicalize with a JCS/RFC 8785 library (the same canonicalize package works in the browser), after blanking both fields.
  • Bytes: new TextEncoder().encode(canonicalString).
  • Import the key once via crypto.subtle.importKey('spki', derBytes, { name: 'Ed25519' }, false, ['verify']) — convert the PEM to DER by stripping the header/footer lines and base64-decoding the body. (A JWK form can be published to skip this step.)
  • Decode the signature from base64 (atobUint8Array).
  • Verify: await crypto.subtle.verify({ name: 'Ed25519' }, key, sigBytes, canonicalBytes).

Same rule, same key, same verdict: a tampered-and-rehashed file fails the signature check in the browser exactly as it does on the server. For older targets without native Ed25519, a vetted, audited pure-JS implementation (e.g. @noble/ed25519) can stand in — but pin the library version and treat it as part of your trust base.


11. Where this lives in the repo

ArtifactPath
Operational verifier (TypeScript)apps/functions/cookpit/appFileValidation.ts
Deployable endpointapps/functions/src/validate-file.tsPOST /api/validate-file
Conformance testapps/functions/tests/appFileValidation.test.ts
Pinned public key (PEM)apps/functions/cookpit/keys/cookpit-chefs-friend.pub.pem
Corpus vectorscookpit.net/recipes · corpus/attested/

The published reference (this document) and the operational module must always produce identical verdicts — the conformance test against the shared corpus is what keeps them from drifting.