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 atapps/functions/cookpit/appFileValidation.tsand is reachable over HTTP atPOST /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, thestatus: "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
| Field | Pinned value |
|---|---|
| Issuer | https://validator.cookpit.org/v3.2 |
| Canonicalization | RFC8785 (JSON Canonicalization Scheme) |
| keyId | cookpit-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:
- Shape. It must be a JSON object with a
cookpit.attestationobject. → otherwiseMALFORMED/NOT_AUTHENTICATED. statusmust equal"authenticated". → otherwiseNOT_AUTHENTICATED.- Every authenticated field (
issuer,validatorVersion,issuedAt,canonicalization,keyId,fileFingerprint,signature) must be a non-empty string. → otherwiseMALFORMED. - Pin checks:
issuer== pinned issuer,canonicalization==RFC8785,keyId==cookpit-chefs-friend. →UNKNOWN_ISSUER,UNSUPPORTED_CANONICALIZATION,UNKNOWN_KEY. - Canonicalize the signed bytes. Deep-clone the file, set both
signatureandfileFingerprintto 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.) - Fingerprint compare — integrity only.
SHA-256of the canonical bytes, lower-case hex, compared to the declaredfileFingerprint. A mismatch means the file was altered (FINGERPRINT_MISMATCH). This is a helpful diagnosis, not the trust decision. - 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
signatureandfileFingerprintbefore 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.
code | severity | Meaning | App response |
|---|---|---|---|
OK | ok | Signature verified against the pinned key | Proceed. |
SIGNATURE_INVALID | security-alert | Cookpit-signed file whose signature fails — altered | Security alert. |
FINGERPRINT_MISMATCH | security-alert | Body doesn't match its fingerprint — altered | Security alert. |
NOT_AUTHENTICATED | info | Unsigned / not a cookpit attestation | Calm rejection. |
MALFORMED | info | Not a cookpit file / corrupt JSON | Calm rejection. |
UNKNOWN_ISSUER / UNKNOWN_KEY / UNSUPPORTED_CANONICALIZATION | info | Signed by something you don't pin | Calm 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) | Expected | declared fileFingerprint |
|---|---|---|
authentic-hungarian-goulash.v3.2.cpt.A.jsonld | ok | 8926a0169ee6f348f40ea77c65e0a95b6606a520643a26ca595579eb71cb7a80 |
perfect-boeuf-bourguignon.v3.2.cpt.A.jsonld | ok | 0acfeb1561839c088af308baa52b649af51d8b14e9188623dced5f8e9caae1fa |
pork-fillet-braised-cheeks-and-pork-belly.v3.2.cpt.A.jsonld | ok | 2db2d949a3d0bff211a0e88c34c7ae0be404c4db17ff9b7dbbb4de6fa2c244a5 |
roast-chicken-with-cider-and-sage.v3.2.cpt.A.jsonld | ok | 2f93c50bc8200e5df315b5278b090b4928476008a9f8ffa32158374088683914 |
spaghetti-carbonara.v3.2.cpt.A.jsonld | ok | c7025d043e283f4b4d0f3ea55f473db5c3a17d2b9f086dc9d371ca7f23af4439 |
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
fileFingerprintso it matches → must still beSIGNATURE_INVALID. (This is the headline attack — the signature exposes the change even though the fingerprint now matches.) - Replace
keyIdwith 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
canonicalizepackage 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 (
atob→Uint8Array). - 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
| Artifact | Path |
|---|---|
| Operational verifier (TypeScript) | apps/functions/cookpit/appFileValidation.ts |
| Deployable endpoint | apps/functions/src/validate-file.ts → POST /api/validate-file |
| Conformance test | apps/functions/tests/appFileValidation.test.ts |
| Pinned public key (PEM) | apps/functions/cookpit/keys/cookpit-chefs-friend.pub.pem |
| Corpus vectors | cookpit.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.