@typedstandards/verify-core 0.1.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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/attestation.d.ts +5 -0
- package/dist/attestation.js +12 -0
- package/dist/blob-ref.d.ts +61 -0
- package/dist/blob-ref.js +127 -0
- package/dist/canonicalization.d.ts +47 -0
- package/dist/canonicalization.js +100 -0
- package/dist/checks.d.ts +128 -0
- package/dist/checks.js +281 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +26 -0
- package/dist/lifecycle.d.ts +83 -0
- package/dist/lifecycle.js +113 -0
- package/dist/primitives.d.ts +14 -0
- package/dist/primitives.js +37 -0
- package/dist/profiles.d.ts +20 -0
- package/dist/profiles.js +38 -0
- package/dist/rekor.d.ts +24 -0
- package/dist/rekor.js +54 -0
- package/dist/signature.d.ts +37 -0
- package/dist/signature.js +81 -0
- package/dist/trust-registry.d.ts +65 -0
- package/dist/trust-registry.js +122 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.js +6 -0
- package/dist/verify.d.ts +96 -0
- package/dist/verify.js +134 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nathan Storey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @typedstandards/verify-core
|
|
2
|
+
|
|
3
|
+
The portable, **browser-safe** verification core for [Typed Standards][ts] evidence
|
|
4
|
+
packages — the spec §9.2 check suite, factored so it runs **identically** on a
|
|
5
|
+
server (e.g. civicaitools.org's verify route) and in the browser (the
|
|
6
|
+
typedstandards.org client-side verifier). One implementation, so a tampered
|
|
7
|
+
package fails the same way wherever it is checked.
|
|
8
|
+
|
|
9
|
+
> Originally extracted from `civic-ai-tools-website` (#116 WS2) and published here
|
|
10
|
+
> (#116 WS3) so every consumer depends on **one versioned source** that cannot
|
|
11
|
+
> drift.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @typedstandards/verify-core
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Browser-safety contract
|
|
20
|
+
|
|
21
|
+
This package depends on **no Node built-ins**. There is no `node:crypto` / `fs` /
|
|
22
|
+
`path` / `process` / `Buffer` anywhere in the source (an upstream ESLint
|
|
23
|
+
`no-restricted-imports` rule enforces it). It runs unchanged in a browser, on the
|
|
24
|
+
edge, and in Node.
|
|
25
|
+
|
|
26
|
+
- **Hashing** — [`@noble/hashes`][nh] (SHA-256/512). Digests are byte-identical to
|
|
27
|
+
Node's `crypto.createHash`.
|
|
28
|
+
- **Signatures** — [`@noble/curves`][nc] Ed25519 / Ed25519ph, with algorithm
|
|
29
|
+
dispatch (a signature verifies only under the scheme it was made with). Raw key
|
|
30
|
+
bytes come from a fixed-prefix SPKI slice, not `crypto.createPublicKey`.
|
|
31
|
+
- **Canonicalization** — RFC 8785 JCS via [`canonicalize`][cz].
|
|
32
|
+
- **Network is injected** — every check that touches the network takes a
|
|
33
|
+
`FetchLike` (defaulting to `globalThis.fetch`, read at call time). The server
|
|
34
|
+
injects its `fetch`, the browser injects `window.fetch`, tests inject a stub.
|
|
35
|
+
|
|
36
|
+
> **Fetch note.** A `FetchLike` should issue **plain GETs with no custom request
|
|
37
|
+
> headers**. A custom header triggers a CORS preflight, and a preflight `OPTIONS`
|
|
38
|
+
> hitting civicaitools.org's site-wide 307 to its canonical host can be rejected.
|
|
39
|
+
> A simple GET follows the 307 transparently.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { verifyEvidence, type VerifyInput, type TrustRegistry } from '@typedstandards/verify-core';
|
|
45
|
+
|
|
46
|
+
const input: VerifyInput = {
|
|
47
|
+
package: pkg, // the parsed package JSON
|
|
48
|
+
packageHash, // the claimed envelope hash
|
|
49
|
+
signature, // { signature, publicKey, algorithm?, kid? }
|
|
50
|
+
rfc3161Timestamp, // optional
|
|
51
|
+
rekorEntryId, // optional
|
|
52
|
+
lifecycle, // optional sidecar lifecycle state
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const result = await verifyEvidence(input, {
|
|
56
|
+
registry, // a TrustRegistry (fetched from the package's trustRegistryUrl)
|
|
57
|
+
fetch: globalThis.fetch,
|
|
58
|
+
});
|
|
59
|
+
// result carries the per-check verdicts: hashMatch, signatureValid, keyTrust,
|
|
60
|
+
// contentHash, typeResolution, signerIdentity, lifecycle, ...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The barrel (`.`) also exports the individual check functions
|
|
64
|
+
(`recomputePackageHash`, `verifySignature`, `verifyKeyTrust`, `verifyContentHash`,
|
|
65
|
+
…), the canonicalization primitives (`computeEnvelopeHash`,
|
|
66
|
+
`computeContentHashSha256`), and every status vocabulary type — so a consumer can
|
|
67
|
+
drive the checks one-at-a-time and render the math as it resolves.
|
|
68
|
+
|
|
69
|
+
## Check depth (v1)
|
|
70
|
+
|
|
71
|
+
Fully client-side: #1 envelope hash, #2 signature, #3 canonicalization, #4 content
|
|
72
|
+
hash, #5 key trust, #6 kid consistency, #9 blob refs, #12 type, #13 nodeId, #14
|
|
73
|
+
signer identity, #15 captureMethod vocab. **Presence** only for #7 (RFC 3161).
|
|
74
|
+
**Hash-parity** only for #8 (Rekor). **State** depth for #10 (lifecycle).
|
|
75
|
+
|
|
76
|
+
The deeper offline crypto — TSA-chain verification, Rekor Merkle-inclusion,
|
|
77
|
+
independent lifecycle-chain verification, and the authoritative bundle-mode test —
|
|
78
|
+
is a fast-follow (#119), not in this release. The UI built on this should be
|
|
79
|
+
honest about what is presence vs. full-crypto.
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT © Nathan Storey
|
|
84
|
+
|
|
85
|
+
[ts]: https://typedstandards.org
|
|
86
|
+
[nh]: https://github.com/paulmillr/noble-hashes
|
|
87
|
+
[nc]: https://github.com/paulmillr/noble-curves
|
|
88
|
+
[cz]: https://github.com/erdtman/canonicalize
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const ATTESTATION_WITHDRAWS = "attestation/withdraws/v1";
|
|
2
|
+
export declare const ATTESTATION_REINSTATES = "attestation/reinstates/v1";
|
|
3
|
+
export type LifecycleAttestationType = typeof ATTESTATION_WITHDRAWS | typeof ATTESTATION_REINSTATES;
|
|
4
|
+
/** The lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
|
|
5
|
+
export declare const LIFECYCLE_ATTESTATION_TYPES: readonly string[];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Lifecycle attestation sub-type URIs (spec §8.10, §8.12.1) — browser-safe
|
|
2
|
+
// constants. The server `attestation.ts` builder (which mints nodes and so needs
|
|
3
|
+
// Node's `crypto.randomUUID`) re-exports these from verify-core, keeping a single
|
|
4
|
+
// definition of the URIs the verify side dispatches on. verify-core never builds
|
|
5
|
+
// nodes — it only verifies and orders them — so it carries the constants alone.
|
|
6
|
+
export const ATTESTATION_WITHDRAWS = 'attestation/withdraws/v1';
|
|
7
|
+
export const ATTESTATION_REINSTATES = 'attestation/reinstates/v1';
|
|
8
|
+
/** The lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
|
|
9
|
+
export const LIFECYCLE_ATTESTATION_TYPES = [
|
|
10
|
+
ATTESTATION_WITHDRAWS,
|
|
11
|
+
ATTESTATION_REINSTATES,
|
|
12
|
+
];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { FetchLike } from './types.ts';
|
|
2
|
+
/** Format of `ref`: `blob:sha256:<64 hex chars>`. */
|
|
3
|
+
export interface BlobRef {
|
|
4
|
+
ref: string;
|
|
5
|
+
url: string;
|
|
6
|
+
contentType: string;
|
|
7
|
+
size: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Detect whether a field value is a BlobRef object. Used by the verifier and the
|
|
11
|
+
* detail-page renderer to branch between inline content and blob-referenced
|
|
12
|
+
* content.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isBlobRef(value: unknown): value is BlobRef;
|
|
15
|
+
export interface ParsedBlobRef {
|
|
16
|
+
algo: 'sha256';
|
|
17
|
+
hash: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a `blob:sha256:<hash>` reference string into its components.
|
|
21
|
+
* Throws if the format is invalid.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseBlobRef(ref: string): ParsedBlobRef;
|
|
24
|
+
export declare const BLOB_REF_VERIFY_REASONS: readonly ["invalid_ref", "fetch_failed", "size_mismatch", "hash_mismatch"];
|
|
25
|
+
export type BlobRefVerifyReason = (typeof BLOB_REF_VERIFY_REASONS)[number];
|
|
26
|
+
export interface BlobRefVerifyResult {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
reason?: BlobRefVerifyReason;
|
|
29
|
+
/** SHA-256 of the fetched blob content, if fetched. Lets callers surface both
|
|
30
|
+
* the expected (ref) and actual hash when they mismatch. */
|
|
31
|
+
computedHash?: string;
|
|
32
|
+
/** Byte length of the fetched blob. */
|
|
33
|
+
computedSize?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Options accepted by the network-touching blob helpers: an injected fetcher
|
|
36
|
+
* (defaults to `globalThis.fetch`, read at call time so a test stub on the
|
|
37
|
+
* global is honored) + an optional abort signal. */
|
|
38
|
+
export interface BlobFetchOptions {
|
|
39
|
+
fetch?: FetchLike;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Fetch a blob and verify that its SHA-256 matches the reference hash and that
|
|
44
|
+
* its byte size matches the metadata. Returns a structured result; never throws.
|
|
45
|
+
*
|
|
46
|
+
* Fetches over HTTPS without auth (Vercel Blob public access is the storage
|
|
47
|
+
* default for evidence content). A plain GET with no custom headers, so it
|
|
48
|
+
* follows civicaitools.org's canonical-host 307 without a CORS preflight.
|
|
49
|
+
*/
|
|
50
|
+
export declare function verifyBlobRef(ref: BlobRef, options?: BlobFetchOptions): Promise<BlobRefVerifyResult>;
|
|
51
|
+
/**
|
|
52
|
+
* Compute the canonical `blob:sha256:<hash>` reference hash for content bytes.
|
|
53
|
+
* Used by uploaders constructing a BlobRef object.
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeBlobRefHash(content: string | Uint8Array): string;
|
|
56
|
+
/**
|
|
57
|
+
* Fetch a BlobRef's content as a UTF-8 string. Used by the detail-page renderer
|
|
58
|
+
* to resolve output-level BlobRefs. Returns null on any failure; callers fall
|
|
59
|
+
* back to showing only the reference metadata rather than blocking the render.
|
|
60
|
+
*/
|
|
61
|
+
export declare function fetchBlobRefText(ref: BlobRef, options?: BlobFetchOptions): Promise<string | null>;
|
package/dist/blob-ref.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Content-addressable blob references for evidence package fields (spec §9.2
|
|
2
|
+
// check #9) — browser-safe.
|
|
3
|
+
//
|
|
4
|
+
// Large fields (full output text, trace JSON, composed skill guidance) can be
|
|
5
|
+
// stored as a separate blob and referenced from the package JSON rather than
|
|
6
|
+
// inlined. The package hash still binds the reference (the BlobRef object is part
|
|
7
|
+
// of the canonical JSON), and the referenced content has its own SHA-256 so
|
|
8
|
+
// consumers verify each piece independently.
|
|
9
|
+
//
|
|
10
|
+
// Factored from the server `blob-ref.ts` (WS2): the SHA-256 backend is
|
|
11
|
+
// `@noble/hashes` and the network fetch is INJECTED (defaulting to the universal
|
|
12
|
+
// `globalThis.fetch`, which the server provides and the browser provides as
|
|
13
|
+
// `window.fetch`). No `node:crypto`, no hardcoded transport.
|
|
14
|
+
import { sha256Hex } from "./primitives.js";
|
|
15
|
+
const REF_PATTERN = /^blob:sha256:([0-9a-f]{64})$/;
|
|
16
|
+
/**
|
|
17
|
+
* Detect whether a field value is a BlobRef object. Used by the verifier and the
|
|
18
|
+
* detail-page renderer to branch between inline content and blob-referenced
|
|
19
|
+
* content.
|
|
20
|
+
*/
|
|
21
|
+
export function isBlobRef(value) {
|
|
22
|
+
if (typeof value !== 'object' || value === null)
|
|
23
|
+
return false;
|
|
24
|
+
const v = value;
|
|
25
|
+
return (typeof v.ref === 'string' &&
|
|
26
|
+
REF_PATTERN.test(v.ref) &&
|
|
27
|
+
typeof v.url === 'string' &&
|
|
28
|
+
typeof v.contentType === 'string' &&
|
|
29
|
+
typeof v.size === 'number');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a `blob:sha256:<hash>` reference string into its components.
|
|
33
|
+
* Throws if the format is invalid.
|
|
34
|
+
*/
|
|
35
|
+
export function parseBlobRef(ref) {
|
|
36
|
+
const match = REF_PATTERN.exec(ref);
|
|
37
|
+
if (!match) {
|
|
38
|
+
throw new Error(`Invalid blob reference: expected "blob:sha256:<64 hex>", got "${ref}"`);
|
|
39
|
+
}
|
|
40
|
+
return { algo: 'sha256', hash: match[1] };
|
|
41
|
+
}
|
|
42
|
+
// Source-of-truth array (not just a type) so the trust-signal vocabulary
|
|
43
|
+
// (civic-ai-tools-website#110) can enumerate every BlobRef failure reason at
|
|
44
|
+
// runtime. Each reason is a sub-explanation of a failed (Alarm-tier) BlobRef
|
|
45
|
+
// integrity check (#9).
|
|
46
|
+
export const BLOB_REF_VERIFY_REASONS = [
|
|
47
|
+
'invalid_ref',
|
|
48
|
+
'fetch_failed',
|
|
49
|
+
'size_mismatch',
|
|
50
|
+
'hash_mismatch',
|
|
51
|
+
];
|
|
52
|
+
function resolveFetch(options) {
|
|
53
|
+
return options.fetch ?? globalThis.fetch;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetch a blob and verify that its SHA-256 matches the reference hash and that
|
|
57
|
+
* its byte size matches the metadata. Returns a structured result; never throws.
|
|
58
|
+
*
|
|
59
|
+
* Fetches over HTTPS without auth (Vercel Blob public access is the storage
|
|
60
|
+
* default for evidence content). A plain GET with no custom headers, so it
|
|
61
|
+
* follows civicaitools.org's canonical-host 307 without a CORS preflight.
|
|
62
|
+
*/
|
|
63
|
+
export async function verifyBlobRef(ref, options = {}) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = parseBlobRef(ref.ref);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { ok: false, reason: 'invalid_ref' };
|
|
70
|
+
}
|
|
71
|
+
const fetcher = resolveFetch(options);
|
|
72
|
+
let response;
|
|
73
|
+
try {
|
|
74
|
+
response = await fetcher(ref.url, {
|
|
75
|
+
signal: options.signal ?? AbortSignal.timeout(15_000),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ok: false, reason: 'fetch_failed' };
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
return { ok: false, reason: 'fetch_failed' };
|
|
83
|
+
}
|
|
84
|
+
let bytes;
|
|
85
|
+
try {
|
|
86
|
+
const buffer = await response.arrayBuffer();
|
|
87
|
+
bytes = new Uint8Array(buffer);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { ok: false, reason: 'fetch_failed' };
|
|
91
|
+
}
|
|
92
|
+
const computedSize = bytes.byteLength;
|
|
93
|
+
if (computedSize !== ref.size) {
|
|
94
|
+
return { ok: false, reason: 'size_mismatch', computedSize };
|
|
95
|
+
}
|
|
96
|
+
const computedHash = sha256Hex(bytes);
|
|
97
|
+
if (computedHash !== parsed.hash) {
|
|
98
|
+
return { ok: false, reason: 'hash_mismatch', computedHash, computedSize };
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, computedHash, computedSize };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Compute the canonical `blob:sha256:<hash>` reference hash for content bytes.
|
|
104
|
+
* Used by uploaders constructing a BlobRef object.
|
|
105
|
+
*/
|
|
106
|
+
export function computeBlobRefHash(content) {
|
|
107
|
+
return sha256Hex(content);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fetch a BlobRef's content as a UTF-8 string. Used by the detail-page renderer
|
|
111
|
+
* to resolve output-level BlobRefs. Returns null on any failure; callers fall
|
|
112
|
+
* back to showing only the reference metadata rather than blocking the render.
|
|
113
|
+
*/
|
|
114
|
+
export async function fetchBlobRefText(ref, options = {}) {
|
|
115
|
+
try {
|
|
116
|
+
const fetcher = resolveFetch(options);
|
|
117
|
+
const response = await fetcher(ref.url, {
|
|
118
|
+
signal: options.signal ?? AbortSignal.timeout(15_000),
|
|
119
|
+
});
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
return null;
|
|
122
|
+
return await response.text();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const LEGACY_JSON_CANONICALIZATION = "https://typedstandards.org/canonicalization/legacy-json/v1";
|
|
2
|
+
export declare const DATHERE_AG_JUPYTER_CANONICALIZATION = "https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1";
|
|
3
|
+
/** Canonicalization-rule URIs this implementation knows how to apply. An
|
|
4
|
+
* unknown URI fails verify check #3 (`unknown_canonicalization_rule`). */
|
|
5
|
+
export declare const KNOWN_CANONICALIZATION_RULES: readonly string[];
|
|
6
|
+
/**
|
|
7
|
+
* RFC 8785 JSON Canonicalization Scheme. Wraps the `canonicalize` reference
|
|
8
|
+
* implementation (by the RFC 8785 author); its return type is
|
|
9
|
+
* `string | undefined`, undefined when the input isn't JSON-canonicalizable
|
|
10
|
+
* (e.g. `undefined`). We throw in that case so a malformed package fails loudly
|
|
11
|
+
* rather than silently producing a wrong hash.
|
|
12
|
+
*/
|
|
13
|
+
export declare function jcs(value: unknown): string;
|
|
14
|
+
/**
|
|
15
|
+
* §8.2 detection rule: a package whose `contentHash` is embedded as a multihash
|
|
16
|
+
* OBJECT (keyed by lowercase algorithm name, hex-string values) is a v0.1
|
|
17
|
+
* package on the JCS chain. Absence — or an external single-SHA-256 hex string
|
|
18
|
+
* carried out of band on the DB row — marks a pre-v0.1 package on the legacy
|
|
19
|
+
* `JSON.stringify` chain.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isMultihashContentHash(value: unknown): value is Record<string, string>;
|
|
22
|
+
/**
|
|
23
|
+
* The envelope hash for a package, routed by the §8.2 detection rule:
|
|
24
|
+
* - v0.1 (multihash `contentHash` present) → SHA-256 of the RFC 8785 JCS
|
|
25
|
+
* canonicalization of the unsigned envelope.
|
|
26
|
+
* - pre-v0.1 (no multihash `contentHash`) → SHA-256 of the legacy
|
|
27
|
+
* insertion-order `JSON.stringify`, preserving byte-identical verification
|
|
28
|
+
* of every package published before the JCS switch.
|
|
29
|
+
*
|
|
30
|
+
* The signature envelope is persisted on the database row, not inside the
|
|
31
|
+
* package object, so the package object already IS the unsigned envelope — there
|
|
32
|
+
* is no `sig` field to strip here. Used by BOTH the packager and the verifier so
|
|
33
|
+
* producer and verifier agree.
|
|
34
|
+
*/
|
|
35
|
+
export declare function computeEnvelopeHash(pkg: Record<string, unknown>): string;
|
|
36
|
+
/**
|
|
37
|
+
* SHA-256 of a package's off-log content, canonicalized per the named rule
|
|
38
|
+
* (spec §8.2). Symmetric across produce/verify: `contentHash` is stripped before
|
|
39
|
+
* hashing, so the result is identical whether the package already carries
|
|
40
|
+
* `contentHash` (verify) or does not yet (packager).
|
|
41
|
+
*
|
|
42
|
+
* - legacy-json/v1 → the canonical-JSON package with `contentHash`
|
|
43
|
+
* (and the sig envelope, which lives off-package here) omitted.
|
|
44
|
+
* - dathere-ag-jupyter/v1 → the executed notebook object
|
|
45
|
+
* (`extensions["org.civicaitools.notebook"]` and its rendered outputs).
|
|
46
|
+
*/
|
|
47
|
+
export declare function computeContentHashSha256(pkg: Record<string, unknown>, rule: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Canonicalization + hashing core (spec §8.2, §8.3.1, §12.3) — browser-safe.
|
|
2
|
+
//
|
|
3
|
+
// Factored from the server `src/lib/evidence/canonicalization.ts` (WS2) with one
|
|
4
|
+
// change: the SHA-256 backend is `@noble/hashes` instead of `node:crypto`, so the
|
|
5
|
+
// same code hashes in the browser. The `canonicalize` (RFC 8785 JCS) reference
|
|
6
|
+
// implementation was already pure and is reused verbatim. The server module now
|
|
7
|
+
// re-exports everything here, so producer and verifier — across both runtimes —
|
|
8
|
+
// remain on a single implementation; a package re-verifies to the exact hash it
|
|
9
|
+
// was published under regardless of where the check runs.
|
|
10
|
+
//
|
|
11
|
+
// The §8.2 detection rule routes each package to its chain: a package whose
|
|
12
|
+
// `contentHash` is a multihash object is a v0.1 package on the JCS chain;
|
|
13
|
+
// absence marks a pre-v0.1 package on the legacy `JSON.stringify` chain.
|
|
14
|
+
import canonicalize from 'canonicalize';
|
|
15
|
+
import { sha256Hex } from "./primitives.js";
|
|
16
|
+
// Content-canonicalization rule URIs (spec §8.2, §12.3). Identifiers, not fetch
|
|
17
|
+
// targets — verifiers resolve them via the local registry below.
|
|
18
|
+
export const LEGACY_JSON_CANONICALIZATION = 'https://typedstandards.org/canonicalization/legacy-json/v1';
|
|
19
|
+
export const DATHERE_AG_JUPYTER_CANONICALIZATION = 'https://typedstandards.org/canonicalization/dathere-ag-jupyter/v1';
|
|
20
|
+
/** Canonicalization-rule URIs this implementation knows how to apply. An
|
|
21
|
+
* unknown URI fails verify check #3 (`unknown_canonicalization_rule`). */
|
|
22
|
+
export const KNOWN_CANONICALIZATION_RULES = [
|
|
23
|
+
LEGACY_JSON_CANONICALIZATION,
|
|
24
|
+
DATHERE_AG_JUPYTER_CANONICALIZATION,
|
|
25
|
+
];
|
|
26
|
+
/** Notebook extension whose object the dathere-ag-jupyter/v1 rule fingerprints
|
|
27
|
+
* (spec §8.2, §8.7.2). */
|
|
28
|
+
const NOTEBOOK_EXTENSION_KEY = 'org.civicaitools.notebook';
|
|
29
|
+
/**
|
|
30
|
+
* RFC 8785 JSON Canonicalization Scheme. Wraps the `canonicalize` reference
|
|
31
|
+
* implementation (by the RFC 8785 author); its return type is
|
|
32
|
+
* `string | undefined`, undefined when the input isn't JSON-canonicalizable
|
|
33
|
+
* (e.g. `undefined`). We throw in that case so a malformed package fails loudly
|
|
34
|
+
* rather than silently producing a wrong hash.
|
|
35
|
+
*/
|
|
36
|
+
export function jcs(value) {
|
|
37
|
+
const out = canonicalize(value);
|
|
38
|
+
if (typeof out !== 'string') {
|
|
39
|
+
throw new Error('JCS canonicalization produced no output (non-JSON value)');
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* §8.2 detection rule: a package whose `contentHash` is embedded as a multihash
|
|
45
|
+
* OBJECT (keyed by lowercase algorithm name, hex-string values) is a v0.1
|
|
46
|
+
* package on the JCS chain. Absence — or an external single-SHA-256 hex string
|
|
47
|
+
* carried out of band on the DB row — marks a pre-v0.1 package on the legacy
|
|
48
|
+
* `JSON.stringify` chain.
|
|
49
|
+
*/
|
|
50
|
+
export function isMultihashContentHash(value) {
|
|
51
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const entries = Object.entries(value);
|
|
55
|
+
return entries.length > 0 && entries.every(([, v]) => typeof v === 'string');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The envelope hash for a package, routed by the §8.2 detection rule:
|
|
59
|
+
* - v0.1 (multihash `contentHash` present) → SHA-256 of the RFC 8785 JCS
|
|
60
|
+
* canonicalization of the unsigned envelope.
|
|
61
|
+
* - pre-v0.1 (no multihash `contentHash`) → SHA-256 of the legacy
|
|
62
|
+
* insertion-order `JSON.stringify`, preserving byte-identical verification
|
|
63
|
+
* of every package published before the JCS switch.
|
|
64
|
+
*
|
|
65
|
+
* The signature envelope is persisted on the database row, not inside the
|
|
66
|
+
* package object, so the package object already IS the unsigned envelope — there
|
|
67
|
+
* is no `sig` field to strip here. Used by BOTH the packager and the verifier so
|
|
68
|
+
* producer and verifier agree.
|
|
69
|
+
*/
|
|
70
|
+
export function computeEnvelopeHash(pkg) {
|
|
71
|
+
if (isMultihashContentHash(pkg['contentHash'])) {
|
|
72
|
+
return sha256Hex(jcs(pkg));
|
|
73
|
+
}
|
|
74
|
+
return sha256Hex(JSON.stringify(pkg));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* SHA-256 of a package's off-log content, canonicalized per the named rule
|
|
78
|
+
* (spec §8.2). Symmetric across produce/verify: `contentHash` is stripped before
|
|
79
|
+
* hashing, so the result is identical whether the package already carries
|
|
80
|
+
* `contentHash` (verify) or does not yet (packager).
|
|
81
|
+
*
|
|
82
|
+
* - legacy-json/v1 → the canonical-JSON package with `contentHash`
|
|
83
|
+
* (and the sig envelope, which lives off-package here) omitted.
|
|
84
|
+
* - dathere-ag-jupyter/v1 → the executed notebook object
|
|
85
|
+
* (`extensions["org.civicaitools.notebook"]` and its rendered outputs).
|
|
86
|
+
*/
|
|
87
|
+
export function computeContentHashSha256(pkg, rule) {
|
|
88
|
+
if (rule === DATHERE_AG_JUPYTER_CANONICALIZATION) {
|
|
89
|
+
const extensions = pkg['extensions'];
|
|
90
|
+
const notebook = extensions?.[NOTEBOOK_EXTENSION_KEY];
|
|
91
|
+
if (notebook === undefined) {
|
|
92
|
+
throw new Error(`dathere-ag-jupyter/v1 content hash requires the ${NOTEBOOK_EXTENSION_KEY} extension`);
|
|
93
|
+
}
|
|
94
|
+
return sha256Hex(jcs(notebook));
|
|
95
|
+
}
|
|
96
|
+
// legacy-json/v1 (default): fingerprint the package minus contentHash.
|
|
97
|
+
const rest = { ...pkg };
|
|
98
|
+
delete rest['contentHash'];
|
|
99
|
+
return sha256Hex(jcs(rest));
|
|
100
|
+
}
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type BlobRefVerifyReason, type BlobFetchOptions } from './blob-ref.ts';
|
|
2
|
+
import type { TrustRegistry } from './trust-registry.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Recompute a package's envelope hash, routed by the §8.2 detection rule: v0.1
|
|
5
|
+
* packages (multihash `contentHash` present) hash via RFC 8785 JCS; pre-v0.1
|
|
6
|
+
* packages hash via the legacy insertion-order `JSON.stringify`. Drives both
|
|
7
|
+
* check #1 (envelope integrity / `hashMatch`) and check #13 (`nodeId`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function recomputePackageHash(pkg: Record<string, unknown>): string;
|
|
10
|
+
export declare const CONTENT_CANONICALIZATION_STATUSES: readonly ["ok", "implicit", "unknown_canonicalization_rule"];
|
|
11
|
+
export type ContentCanonicalizationStatus = (typeof CONTENT_CANONICALIZATION_STATUSES)[number];
|
|
12
|
+
export interface ContentCanonicalizationResolution {
|
|
13
|
+
status: ContentCanonicalizationStatus;
|
|
14
|
+
/** The resolved canonicalization-rule URI — the explicit field value, or the
|
|
15
|
+
* rule inferred from the content profile for pre-v0.1 packages. */
|
|
16
|
+
rule: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Check #3 — content-canonicalization rule resolution (spec §9.2). Reads the
|
|
20
|
+
* package's `contentCanonicalization` URI and resolves it against the local rule
|
|
21
|
+
* registry. A known URI → `ok`. An unrecognized URI →
|
|
22
|
+
* `unknown_canonicalization_rule` (renders; check #4 then cannot recompute). An
|
|
23
|
+
* absent field on a pre-v0.1 package → `implicit`, inferring the rule from
|
|
24
|
+
* contentProfile / producerProfile (datHere → dathere-ag-jupyter/v1; otherwise
|
|
25
|
+
* legacy-json/v1).
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveContentCanonicalization(pkg: Record<string, unknown>): ContentCanonicalizationResolution;
|
|
28
|
+
export declare const CONTENT_HASH_STATUSES: readonly ["ok", "content_hash_mismatch", "contentHash_no_supported_algorithm", "unresolved_rule", "legacy_relabeled"];
|
|
29
|
+
export type ContentHashStatus = (typeof CONTENT_HASH_STATUSES)[number];
|
|
30
|
+
export interface ContentHashCheck {
|
|
31
|
+
status: ContentHashStatus;
|
|
32
|
+
/** Algorithm names listed in the package's multihash `contentHash`. */
|
|
33
|
+
algorithms?: string[];
|
|
34
|
+
/** The algorithm whose recomputed digest matched (status === 'ok'). */
|
|
35
|
+
matched?: string;
|
|
36
|
+
/** The multihash digest set the verifier reports for the package. For pre-v0.1
|
|
37
|
+
* packages this is the legacy external single-SHA-256 relabeled as
|
|
38
|
+
* `{ sha256: <hex> }` per §8.2. */
|
|
39
|
+
contentHash?: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Check #4 — content-hash verification (spec §9.2, §8.2).
|
|
43
|
+
*
|
|
44
|
+
* v0.1 packages (multihash `contentHash` present): recompute the off-log
|
|
45
|
+
* content's digest under the resolved canonicalization rule for every listed
|
|
46
|
+
* algorithm this verifier supports, and confirm at least one matches.
|
|
47
|
+
* - at least one supported algorithm matches → `ok`.
|
|
48
|
+
* - a supported algorithm is present but none match → `content_hash_mismatch`.
|
|
49
|
+
* - none of the listed algorithms are ones this verifier can compute →
|
|
50
|
+
* `contentHash_no_supported_algorithm` (degrades; the value is preserved).
|
|
51
|
+
* - the canonicalization rule could not be resolved (check #3 unknown) →
|
|
52
|
+
* `unresolved_rule` (cannot recompute).
|
|
53
|
+
*
|
|
54
|
+
* pre-v0.1 packages (no multihash `contentHash`): the historical external
|
|
55
|
+
* single-SHA-256 is RELABELED as `{ sha256: <hex> }` per §8.2 rather than
|
|
56
|
+
* recomputed; its integrity is established by check #1, so check #4 reports
|
|
57
|
+
* `legacy_relabeled`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function verifyContentHash(pkg: Record<string, unknown>, resolution: ContentCanonicalizationResolution, legacyExternalHash?: string): ContentHashCheck;
|
|
60
|
+
export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
|
|
61
|
+
export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
|
|
62
|
+
export interface TypeResolution {
|
|
63
|
+
status: TypeResolutionStatus;
|
|
64
|
+
/** The resolved type URI (the implicit `content/analysis/v1` when omitted). */
|
|
65
|
+
type: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Check #12 — `type` resolution. An absent field resolves to the implicit
|
|
69
|
+
* `content/analysis/v1` (pre-v0.1). An unrecognized URI reports `unknown_type`
|
|
70
|
+
* and renders as such — it does NOT fail verification.
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolvePackageType(pkg: Record<string, unknown>): TypeResolution;
|
|
73
|
+
export declare const SIGNER_IDENTITY_CHECK_STATUSES: readonly ["ok", "signer_identity_mismatch", "no_signer", "no_registry_identity"];
|
|
74
|
+
export type SignerIdentityCheckStatus = (typeof SIGNER_IDENTITY_CHECK_STATUSES)[number];
|
|
75
|
+
export interface SignerIdentityCheck {
|
|
76
|
+
status: SignerIdentityCheckStatus;
|
|
77
|
+
/** The `signer.identifier` claimed in the envelope, when present. */
|
|
78
|
+
claimed?: string;
|
|
79
|
+
/** The identifier the registry records for the signing `kid`, when present. */
|
|
80
|
+
registered?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check #14 — `signer.identifier` ↔ trust-registry `signerIdentity` cross-check
|
|
84
|
+
* (rules out a kid-swap-with-mismatched-identity attack). `signer_identity_mismatch`
|
|
85
|
+
* is fatal. Pre-v0.1 packages carry no envelope-side `signer` (`no_signer`) — the
|
|
86
|
+
* verifier derives the signer from the registry and skips the cross-check. A
|
|
87
|
+
* registry entry without a `signerIdentity` (legacy registry) yields
|
|
88
|
+
* `no_registry_identity`.
|
|
89
|
+
*/
|
|
90
|
+
export declare function checkSignerIdentity(pkg: Record<string, unknown>, kid: string | undefined, registry: TrustRegistry | undefined): SignerIdentityCheck;
|
|
91
|
+
export declare const CAPTURE_METHOD_VOCAB_STATUSES: readonly ["ok", "captureMethod_unknown", "producerProfile_bundle_unresolved", "no_capture_method"];
|
|
92
|
+
export type CaptureMethodVocabStatus = (typeof CAPTURE_METHOD_VOCAB_STATUSES)[number];
|
|
93
|
+
export interface CaptureMethodVocabCheck {
|
|
94
|
+
status: CaptureMethodVocabStatus;
|
|
95
|
+
captureMethod?: string;
|
|
96
|
+
/** The resolved Producer Profile type whose vocabulary was consulted. */
|
|
97
|
+
profileType: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Check #15 — `captureMethod` per-profile vocabulary conformance. Resolves the
|
|
101
|
+
* package's Producer Profile (or its legacy-alias / pre-v0.1 fallback) and
|
|
102
|
+
* confirms `metadata.captureMethod` is in the declared vocabulary. A value not in
|
|
103
|
+
* the vocabulary reports `captureMethod_unknown` (rejects). An unresolvable
|
|
104
|
+
* profile bundle reports `producerProfile_bundle_unresolved` and degrades
|
|
105
|
+
* gracefully. A null captureMethod (pre-v0.1) is neutral (`no_capture_method`).
|
|
106
|
+
*/
|
|
107
|
+
export declare function checkCaptureMethodVocab(pkg: Record<string, unknown>): CaptureMethodVocabCheck;
|
|
108
|
+
/** Field paths that the core verifier scans for BlobRef objects. */
|
|
109
|
+
declare const BLOB_REF_FIELDS: readonly ["output", "trace", "skillMetadata.skillText"];
|
|
110
|
+
export type BlobRefField = (typeof BLOB_REF_FIELDS)[number];
|
|
111
|
+
export interface BlobRefVerification {
|
|
112
|
+
field: BlobRefField;
|
|
113
|
+
ref: string;
|
|
114
|
+
url: string;
|
|
115
|
+
size: number;
|
|
116
|
+
contentType: string;
|
|
117
|
+
ok: boolean;
|
|
118
|
+
reason?: BlobRefVerifyReason;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Walk the package JSON for BlobRef fields, fetch each referenced blob, and
|
|
122
|
+
* confirm the bytes hash to the advertised ref. Returns per-field verification
|
|
123
|
+
* results. Runs fetches in parallel with a per-blob 15 s timeout; failures are
|
|
124
|
+
* reported in the result rather than thrown. The fetcher is injected (defaults to
|
|
125
|
+
* `globalThis.fetch`).
|
|
126
|
+
*/
|
|
127
|
+
export declare function verifyPackageBlobRefs(pkg: Record<string, unknown>, options?: BlobFetchOptions): Promise<BlobRefVerification[]>;
|
|
128
|
+
export {};
|