@dynamicfeed/verify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +50 -0
  2. package/cli.js +36 -0
  3. package/index.d.ts +39 -0
  4. package/index.js +117 -0
  5. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @dynamicfeed/verify
2
+
3
+ Independently verify [Dynamic Feed](https://dynamicfeed.ai) **DF-VERIFY/1** Ed25519-signed responses — in any JavaScript runtime (Node ≥18, Deno, Bun, browsers). No account, no runtime trust in Dynamic Feed beyond fetching the public key. You can verify, even against us.
4
+
5
+ Reference implementation of the [DF-VERIFY/1 standard](https://dynamicfeed.ai/standard). Byte-for-byte identical canonicalization to the Python (`dynamicfeed-verify`) and in-browser verifiers.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @dynamicfeed/verify
11
+ ```
12
+
13
+ ## Use
14
+
15
+ ```js
16
+ import { verify, verifyLive } from '@dynamicfeed/verify';
17
+
18
+ // 1) fetch a fresh signed awareness verdict and verify it
19
+ const { text, result } = await verifyLive();
20
+ console.log(result); // { ok: true, keyId: 'df-ed25519-…', verdict: 'caution', ... }
21
+
22
+ // 2) verify any signed response you hold — pass the RAW text for byte-fidelity
23
+ const result2 = await verify(rawResponseText);
24
+ if (!result2.ok) throw new Error(`unverified: ${result2.error}`);
25
+
26
+ // 3) verify fully offline if you already have the JWKS
27
+ const result3 = await verify(rawResponseText, { jwks: { 'df-ed25519-…': '<base64url public key>' } });
28
+ ```
29
+
30
+ ## CLI
31
+
32
+ ```bash
33
+ npx @dynamicfeed/verify # fetch a live verdict + verify
34
+ npx @dynamicfeed/verify - < response.json # verify a saved signed response
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ A signed response carries a `signature` block (`alg`, `key_id`, `canonicalization`, `sig`). Verification:
40
+
41
+ 1. Drop the `signature` field; keep the rest as the payload.
42
+ 2. Canonicalize — JSON, keys sorted recursively, compact separators (`,` `:`), non-ASCII escaped `\uXXXX`, UTF-8. (Numbers are preserved verbatim via a lossless parse, so it matches the signer byte-for-byte.)
43
+ 3. Fetch the public key from `/.well-known/keys` and look up `signature.key_id`.
44
+ 4. Verify the Ed25519 signature over the canonical bytes. Change one byte → it fails.
45
+
46
+ Full specification: **https://dynamicfeed.ai/standard**
47
+
48
+ ## License
49
+
50
+ MIT.
package/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // CLI: `dynamicfeed-verify` — fetch a live signed verdict and verify it, or verify a saved response.
3
+ import { verifyLive, verify, DEFAULT_BASE } from './index.js';
4
+
5
+ function report(res) {
6
+ if (res.ok) {
7
+ let extra = '';
8
+ if (res.verdict) extra += ` · verdict=${res.verdict}`;
9
+ if (res.snapshot) extra += ` · snapshot=${res.snapshot}`;
10
+ if (res.ephemeral) extra += ' · EPHEMERAL key';
11
+ console.log(`✅ VALID — key=${res.keyId}${extra}`);
12
+ process.exit(0);
13
+ }
14
+ console.log(`✗ INVALID — ${res.error}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ const a = process.argv.slice(2);
19
+ if (a[0] === '-h' || a[0] === '--help') {
20
+ console.log('usage:\n' +
21
+ ' dynamicfeed-verify [BASE_URL] fetch a live signed verdict and verify it\n' +
22
+ ' dynamicfeed-verify - < response.json verify a saved signed response\n' +
23
+ ` default BASE_URL = ${DEFAULT_BASE} · spec: ${DEFAULT_BASE}/standard`);
24
+ process.exit(0);
25
+ }
26
+ if (a[0] === '-') {
27
+ let s = '';
28
+ process.stdin.setEncoding('utf8');
29
+ for await (const chunk of process.stdin) s += chunk;
30
+ report(await verify(s, { base: a[1] || DEFAULT_BASE }));
31
+ } else {
32
+ const base = a[0] || DEFAULT_BASE;
33
+ console.log(`requesting a live signed verdict from ${base}/v1/awareness ...`);
34
+ const { result } = await verifyLive(base);
35
+ report(result);
36
+ }
package/index.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Type definitions for @dynamicfeed/verify — DF-VERIFY/1 reference verifier.
2
+ // https://dynamicfeed.ai/standard
3
+
4
+ export declare const DEFAULT_BASE: string;
5
+
6
+ export interface VerifyResult {
7
+ /** true iff the Ed25519 signature is authentic over the canonical payload. */
8
+ ok: boolean;
9
+ /** present when ok=false: why verification failed. */
10
+ error?: string;
11
+ keyId?: string;
12
+ alg?: string;
13
+ /** the canonicalization named in the signature block (e.g. "json-sorted-compact"). */
14
+ canon?: string;
15
+ /** true if signed with a demo/ephemeral key (will not verify after issuer restart). */
16
+ ephemeral?: boolean;
17
+ /** the verdict status (awareness/v1 profile), if present. */
18
+ verdict?: string | null;
19
+ snapshot?: string;
20
+ }
21
+
22
+ export interface VerifyOptions {
23
+ /** base URL to fetch the public key set from (default https://dynamicfeed.ai). */
24
+ base?: string;
25
+ /** supply a JWKS map (key_id → base64url public key) to verify fully offline. */
26
+ jwks?: Record<string, string>;
27
+ }
28
+
29
+ /** The exact bytes that were signed: the response minus its `signature`, json-sorted-compact. */
30
+ export declare function canonical(input: string | object): string;
31
+
32
+ /** Verify a DF-VERIFY/1 signed response. Pass the RAW response text for byte-fidelity. */
33
+ export declare function verify(input: string | object, opts?: VerifyOptions): Promise<VerifyResult>;
34
+
35
+ /** Fetch a fresh signed awareness verdict and verify it. */
36
+ export declare function verifyLive(
37
+ base?: string,
38
+ body?: object
39
+ ): Promise<{ text: string; result: VerifyResult }>;
package/index.js ADDED
@@ -0,0 +1,117 @@
1
+ // @dynamicfeed/verify — independently verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses.
2
+ //
3
+ // Runtime-agnostic ESM (Node >=18, Deno, Bun, browsers/bundlers). Reproduces the server's
4
+ // canonicalization BYTE-FOR-BYTE — json-sorted-compact, Python `ensure_ascii` escaping, the
5
+ // `signature` field stripped, numbers preserved verbatim via a lossless parse — then verifies the
6
+ // detached Ed25519 signature against the published JWKS using @noble/ed25519. Proven identical to
7
+ // the Python (scripts/verify_awareness.py) and in-browser (web/verify.js) reference verifiers.
8
+ //
9
+ // Spec: https://dynamicfeed.ai/standard
10
+ import * as ed from '@noble/ed25519';
11
+
12
+ export const DEFAULT_BASE = 'https://dynamicfeed.ai';
13
+
14
+ function b64u(s) {
15
+ s = s.replace(/-/g, '+').replace(/_/g, '/');
16
+ s += '='.repeat((4 - (s.length % 4)) % 4);
17
+ const bin = atob(s), u = new Uint8Array(bin.length);
18
+ for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
19
+ return u;
20
+ }
21
+
22
+ // lossless JSON parse — numbers kept VERBATIM so re-canonicalization matches Python's repr exactly
23
+ function lparse(s) {
24
+ let i = 0;
25
+ function ws() { while (i < s.length) { const c = s[i]; if (c === ' ' || c === '\t' || c === '\n' || c === '\r') i++; else break; } }
26
+ function val() { ws(); const c = s[i];
27
+ if (c === '{') return obj(); if (c === '[') return arr(); if (c === '"') return { t: 's', v: str() };
28
+ if (c === 't') { i += 4; return { t: 'b', v: true }; } if (c === 'f') { i += 5; return { t: 'b', v: false }; }
29
+ if (c === 'n') { i += 4; return { t: 'z' }; } return num(); }
30
+ function obj() { i++; const p = []; ws(); if (s[i] === '}') { i++; return { t: 'o', v: p }; }
31
+ for (;;) { ws(); const k = str(); ws(); i++; const v = val(); p.push([k, v]); ws();
32
+ if (s[i] === ',') { i++; continue; } if (s[i] === '}') { i++; break; } throw new Error('object @' + i); } return { t: 'o', v: p }; }
33
+ function arr() { i++; const a = []; ws(); if (s[i] === ']') { i++; return { t: 'a', v: a }; }
34
+ for (;;) { a.push(val()); ws(); if (s[i] === ',') { i++; continue; } if (s[i] === ']') { i++; break; } throw new Error('array @' + i); } return { t: 'a', v: a }; }
35
+ function str() { let r = ''; i++; while (s[i] !== '"') { if (s[i] === '\\') { const e = s[i + 1];
36
+ if (e === 'u') { r += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16)); i += 6; }
37
+ else { r += ({ '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' })[e]; i += 2; } }
38
+ else { r += s[i]; i++; } } i++; return r; }
39
+ function num() { const a = i; while (i < s.length && '-+0123456789.eE'.indexOf(s[i]) >= 0) i++; return { t: 'n', v: s.slice(a, i) }; }
40
+ return val();
41
+ }
42
+
43
+ // Python json.dumps string escaping with ensure_ascii=True
44
+ function pys(str) { let o = '"'; for (const ch of str) { const c = ch.codePointAt(0);
45
+ if (ch === '"') o += '\\"'; else if (ch === '\\') o += '\\\\';
46
+ else if (ch === '\n') o += '\\n'; else if (ch === '\r') o += '\\r'; else if (ch === '\t') o += '\\t';
47
+ else if (ch === '\b') o += '\\b'; else if (ch === '\f') o += '\\f';
48
+ else if (c < 0x20) o += '\\u' + c.toString(16).padStart(4, '0');
49
+ else if (c <= 0x7e) o += ch;
50
+ else if (c > 0xffff) { const x = c - 0x10000; o += '\\u' + (0xd800 + (x >> 10)).toString(16).padStart(4, '0') + '\\u' + (0xdc00 + (x & 0x3ff)).toString(16).padStart(4, '0'); }
51
+ else o += '\\u' + c.toString(16).padStart(4, '0'); }
52
+ return o + '"'; }
53
+
54
+ function canon(n) { if (n.t === 'o') { const keys = n.v.map(p => p[0]).sort(); const m = {}; n.v.forEach(p => m[p[0]] = p[1]);
55
+ return '{' + keys.map(k => pys(k) + ':' + canon(m[k])).join(',') + '}'; }
56
+ if (n.t === 'a') return '[' + n.v.map(canon).join(',') + ']';
57
+ if (n.t === 's') return pys(n.v); if (n.t === 'n') return n.v;
58
+ if (n.t === 'b') return n.v ? 'true' : 'false'; return 'null'; }
59
+
60
+ function field(node, key) { if (!node || node.t !== 'o') return null; for (const [k, v] of node.v) if (k === key) return v; return null; }
61
+
62
+ async function fetchJwks(base) { const r = await fetch(base + '/.well-known/keys'); if (!r.ok) throw new Error('HTTP ' + r.status); return await r.json(); }
63
+
64
+ /**
65
+ * The exact bytes that were signed: the response minus its `signature` field, json-sorted-compact.
66
+ * Pass the RAW response text for guaranteed byte-fidelity (numbers are preserved verbatim).
67
+ * @param {string|object} input
68
+ * @returns {string}
69
+ */
70
+ export function canonical(input) {
71
+ const text = typeof input === 'string' ? input : JSON.stringify(input);
72
+ const root = lparse(text.trim());
73
+ const stripped = root.t === 'o' ? { t: 'o', v: root.v.filter(p => p[0] !== 'signature') } : root;
74
+ return canon(stripped);
75
+ }
76
+
77
+ /**
78
+ * Verify a DF-VERIFY/1 signed response.
79
+ * @param {string|object} input Raw response text (preferred) or a parsed object.
80
+ * @param {{base?:string, jwks?:Record<string,string>}} [opts] base URL for key fetch; or supply jwks to verify offline.
81
+ * @returns {Promise<{ok:boolean, error?:string, keyId?:string, alg?:string, canon?:string, ephemeral?:boolean, verdict?:string|null, snapshot?:string}>}
82
+ */
83
+ export async function verify(input, opts = {}) {
84
+ const base = (opts.base || DEFAULT_BASE).replace(/\/$/, '');
85
+ const text = typeof input === 'string' ? input : JSON.stringify(input);
86
+ let root;
87
+ try { root = lparse(text.trim()); } catch (e) { return { ok: false, error: 'invalid JSON — ' + e.message }; }
88
+ if (root.t !== 'o') return { ok: false, error: 'expected a JSON object' };
89
+ const sig = field(root, 'signature');
90
+ if (!sig) return { ok: false, error: 'no "signature" block in this response' };
91
+ const keyId = (field(sig, 'key_id') || {}).v, sigB64 = (field(sig, 'sig') || {}).v;
92
+ const alg = (field(sig, 'alg') || {}).v, canonName = (field(sig, 'canonicalization') || {}).v, ephN = field(sig, 'ephemeral_key');
93
+ if (!keyId || !sigB64) return { ok: false, error: 'signature block missing key_id or sig' };
94
+ let ks = opts.jwks;
95
+ if (!ks) { try { ks = await fetchJwks(base); } catch (e) { return { ok: false, error: 'could not fetch the public key set' }; } }
96
+ if (!(keyId in ks)) return { ok: false, error: 'key_id ' + keyId + ' not in published JWKS (rotated?)', keyId };
97
+ const stripped = { t: 'o', v: root.v.filter(p => p[0] !== 'signature') };
98
+ const msg = new TextEncoder().encode(canon(stripped));
99
+ let ok = false;
100
+ try { ok = await ed.verifyAsync(b64u(sigB64), msg, b64u(ks[keyId])); }
101
+ catch (e) { return { ok: false, error: 'verify error — ' + e.message, keyId }; }
102
+ const vNode = field(root, 'verdict');
103
+ return { ok, keyId, alg, canon: canonName, ephemeral: !!(ephN && ephN.v),
104
+ verdict: vNode ? (field(vNode, 'status') || {}).v : null, snapshot: (field(root, 'snapshot_id') || {}).v };
105
+ }
106
+
107
+ /**
108
+ * Fetch a fresh keyless signed awareness verdict and verify it.
109
+ * @returns {Promise<{text:string, result:Awaited<ReturnType<typeof verify>>}>}
110
+ */
111
+ export async function verifyLive(base = DEFAULT_BASE, body = { robot: { class: 'aerial' }, location: { lat: 51.5, lon: -0.12 } }) {
112
+ const b = base.replace(/\/$/, '');
113
+ const r = await fetch(b + '/v1/awareness', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
114
+ const text = await r.text();
115
+ const result = await verify(text, { base: b });
116
+ return { text, result };
117
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@dynamicfeed/verify",
3
+ "version": "1.0.0",
4
+ "description": "Independently verify Dynamic Feed (DF-VERIFY/1) Ed25519-signed responses \u2014 in any JS runtime.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "dynamicfeed-verify": "cli.js"
16
+ },
17
+ "files": [
18
+ "index.js",
19
+ "index.d.ts",
20
+ "cli.js",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "dynamic-feed",
25
+ "df-verify",
26
+ "ed25519",
27
+ "verification",
28
+ "provenance",
29
+ "attestation",
30
+ "ai",
31
+ "agents"
32
+ ],
33
+ "homepage": "https://dynamicfeed.ai/standard",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/dynamicfeed/df-verify.git"
37
+ },
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@noble/ed25519": "^2.0.0"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }