@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { createReadStream } from 'node:fs';
|
|
2
1
|
import { MAX_OFFLINE_ARTIFACT_BYTES, MAX_OFFLINE_PASSPHRASE_FILE_BYTES, } from "./artifact-verify.mjs";
|
|
3
2
|
import { formatArchiveInspection, inspectWorkspaceArchive, } from "./archive-inspect.mjs";
|
|
4
3
|
import { MAX_SIGNING_KEY_FILE_BYTES, formatEvidenceSignatureVerification, signEvidencePackage, verifyEvidencePackageSignature, } from "./evidence-signing.mjs";
|
|
5
4
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
6
5
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
7
6
|
import { formatJsonDocument } from "./formatters/json.mjs";
|
|
8
|
-
import {
|
|
7
|
+
import { readCliTextInput } from "./input.mjs";
|
|
9
8
|
export function isEvidenceCommand(args) {
|
|
10
9
|
return args.action === 'inspect-archive'
|
|
11
10
|
|| args.action === 'sign-artifact'
|
|
@@ -22,11 +21,10 @@ async function readArtifact(source, label, dependencies) {
|
|
|
22
21
|
try {
|
|
23
22
|
return dependencies.readArtifactInput
|
|
24
23
|
? await dependencies.readArtifactInput(source)
|
|
25
|
-
: await
|
|
26
|
-
|
|
27
|
-
: dependencies.stdin, {
|
|
28
|
-
limit: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
24
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
25
|
+
maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
29
26
|
label,
|
|
27
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
30
28
|
});
|
|
31
29
|
}
|
|
32
30
|
catch (error) {
|
|
@@ -35,11 +33,15 @@ async function readArtifact(source, label, dependencies) {
|
|
|
35
33
|
throw new CliUsageError(`Could not read ${label.toLowerCase()}: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
|
-
async function readKey(source, label, injected) {
|
|
36
|
+
async function readKey(source, label, injected, dependencies) {
|
|
39
37
|
try {
|
|
40
38
|
return injected
|
|
41
39
|
? await injected(source)
|
|
42
|
-
: await
|
|
40
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
41
|
+
maximumBytes: MAX_SIGNING_KEY_FILE_BYTES,
|
|
42
|
+
label,
|
|
43
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
44
|
+
});
|
|
43
45
|
}
|
|
44
46
|
catch (error) {
|
|
45
47
|
if (error instanceof CliUsageError)
|
|
@@ -50,7 +52,11 @@ async function readKey(source, label, injected) {
|
|
|
50
52
|
async function readPassphrase(source, dependencies) {
|
|
51
53
|
const passphraseText = dependencies.readPassphraseFile
|
|
52
54
|
? await dependencies.readPassphraseFile(source)
|
|
53
|
-
: await
|
|
55
|
+
: await readCliTextInput(source, dependencies.stdin, {
|
|
56
|
+
maximumBytes: MAX_OFFLINE_PASSPHRASE_FILE_BYTES,
|
|
57
|
+
label: 'Passphrase file',
|
|
58
|
+
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
59
|
+
});
|
|
54
60
|
const passphrase = passphraseText.replace(/\r?\n$/u, '');
|
|
55
61
|
if (!passphrase || /[\r\n\u0000]/u.test(passphrase)) {
|
|
56
62
|
throw new CliUsageError('Passphrase file must contain exactly one non-empty UTF-8 line.');
|
|
@@ -85,7 +91,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
85
91
|
if (!input.trim()) {
|
|
86
92
|
throw new CliUsageError('sign-artifact requires one reviewed artefact file or an artefact on stdin.');
|
|
87
93
|
}
|
|
88
|
-
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile);
|
|
94
|
+
const privateKey = await readKey(args.privateKeySource, 'Private key file', dependencies.readPrivateKeyFile, dependencies);
|
|
89
95
|
const signed = await signEvidencePackage(input, privateKey, dependencies.now ? dependencies.now() : new Date().toISOString());
|
|
90
96
|
dependencies.stdout.write(formatJsonDocument(signed));
|
|
91
97
|
return EXIT_CODES.SUCCESS;
|
|
@@ -95,7 +101,7 @@ export async function runEvidenceCommand(args, dependencies) {
|
|
|
95
101
|
throw new CliUsageError('verify-signature requires one signed package file or a package on stdin.');
|
|
96
102
|
}
|
|
97
103
|
const publicKey = args.publicKeySource
|
|
98
|
-
? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile)
|
|
104
|
+
? await readKey(args.publicKeySource, 'Public key file', dependencies.readPublicKeyFile, dependencies)
|
|
99
105
|
: null;
|
|
100
106
|
const report = await verifyEvidencePackageSignature(input, publicKey);
|
|
101
107
|
if (!args.quiet) {
|
package/cli/evidence-signing.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
3
|
+
import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, SORTED_JSON_V1, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { hasVerifiedApplicableIntegrity, hasVerifiedArtifactStructure, MAX_OFFLINE_ARTIFACT_BYTES, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
5
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
5
6
|
export const SIGNED_EVIDENCE_PACKAGE_SCHEMA = 'whoisleuth.signed-evidence-package';
|
|
6
|
-
export const SIGNED_EVIDENCE_PACKAGE_VERSION =
|
|
7
|
+
export const SIGNED_EVIDENCE_PACKAGE_VERSION = 2;
|
|
8
|
+
export const LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION = 1;
|
|
9
|
+
export const EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA = 'whoisleuth.evidence-signature-verification';
|
|
10
|
+
export const EVIDENCE_SIGNATURE_VERIFICATION_VERSION = 2;
|
|
7
11
|
export const EVIDENCE_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
8
|
-
export const EVIDENCE_SIGNATURE_CANONICALIZATION =
|
|
12
|
+
export const EVIDENCE_SIGNATURE_CANONICALIZATION = SORTED_JSON_V2;
|
|
9
13
|
export const MAX_SIGNING_KEY_FILE_BYTES = 16 * 1024;
|
|
10
14
|
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
11
15
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
@@ -23,23 +27,7 @@ function record(value) {
|
|
|
23
27
|
: null;
|
|
24
28
|
}
|
|
25
29
|
function parseObject(raw, label) {
|
|
26
|
-
|
|
27
|
-
throw new TypeError(`${label} must be UTF-8 JSON text.`);
|
|
28
|
-
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
29
|
-
if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
|
|
30
|
-
throw new TypeError(`${label} must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
|
|
31
|
-
}
|
|
32
|
-
let parsed;
|
|
33
|
-
try {
|
|
34
|
-
parsed = JSON.parse(raw);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new TypeError(`${label} must be valid JSON.`);
|
|
38
|
-
}
|
|
39
|
-
const value = record(parsed);
|
|
40
|
-
if (!value)
|
|
41
|
-
throw new TypeError(`${label} must contain one JSON object.`);
|
|
42
|
-
return value;
|
|
30
|
+
return parseBoundedJsonObject(raw, { label, maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES });
|
|
43
31
|
}
|
|
44
32
|
function hasExactKeys(value, expected) {
|
|
45
33
|
const keys = Object.keys(value);
|
|
@@ -54,6 +42,12 @@ function timestamp(value) {
|
|
|
54
42
|
throw new TypeError('Signature time must be a valid timestamp.');
|
|
55
43
|
return new Date(parsed).toISOString();
|
|
56
44
|
}
|
|
45
|
+
function canonicalTimestamp(value) {
|
|
46
|
+
const normalized = timestamp(value);
|
|
47
|
+
if (value !== normalized)
|
|
48
|
+
throw new TypeError('Signature time must use canonical UTC timestamp text.');
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
57
51
|
function importPrivateKey(pem) {
|
|
58
52
|
if (Buffer.byteLength(pem, 'utf8') > MAX_SIGNING_KEY_FILE_BYTES) {
|
|
59
53
|
throw new TypeError(`Private key file exceeds ${MAX_SIGNING_KEY_FILE_BYTES} bytes.`);
|
|
@@ -96,8 +90,8 @@ function spkiDer(key) {
|
|
|
96
90
|
function keyId(publicDer) {
|
|
97
91
|
return createHash('sha256').update(publicDer).digest('hex');
|
|
98
92
|
}
|
|
99
|
-
function payloadBytes(payload) {
|
|
100
|
-
return Buffer.from(
|
|
93
|
+
function payloadBytes(payload, canonicalization) {
|
|
94
|
+
return Buffer.from(canonicalArtifactJsonFor(payload, canonicalization), 'utf8');
|
|
101
95
|
}
|
|
102
96
|
async function signableArtifact(raw) {
|
|
103
97
|
const artifact = parseObject(raw, 'Evidence artefact');
|
|
@@ -105,6 +99,9 @@ async function signableArtifact(raw) {
|
|
|
105
99
|
if (!['case_response_packet', 'investigation_capsule', 'signed_review_artifact'].includes(verification.artifact.kind)) {
|
|
106
100
|
throw new TypeError('Only reviewed response packets, investigation capsules, and supported review manifests can be signed.');
|
|
107
101
|
}
|
|
102
|
+
if (!hasVerifiedArtifactStructure(verification) || !hasVerifiedApplicableIntegrity(verification)) {
|
|
103
|
+
throw new TypeError('Evidence signing requires verified structure and a verified applicable integrity contract.');
|
|
104
|
+
}
|
|
108
105
|
return { artifact, verification };
|
|
109
106
|
}
|
|
110
107
|
export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Date().toISOString()) {
|
|
@@ -124,7 +121,7 @@ export async function signEvidencePackage(raw, privateKeyPem, signedAt = new Dat
|
|
|
124
121
|
canonicalization: EVIDENCE_SIGNATURE_CANONICALIZATION,
|
|
125
122
|
publicKeySpkiDerBase64: publicDer.toString('base64'),
|
|
126
123
|
keyIdSha256: keyId(publicDer),
|
|
127
|
-
valueBase64: sign(null, payloadBytes(payload), privateKey).toString('base64'),
|
|
124
|
+
valueBase64: sign(null, payloadBytes(payload, SORTED_JSON_V2), privateKey).toString('base64'),
|
|
128
125
|
}),
|
|
129
126
|
});
|
|
130
127
|
}
|
|
@@ -134,12 +131,11 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
134
131
|
const artifact = record(value.artifact);
|
|
135
132
|
if (!hasExactKeys(value, PACKAGE_KEYS)
|
|
136
133
|
|| value.schema !== SIGNED_EVIDENCE_PACKAGE_SCHEMA
|
|
137
|
-
|| value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION
|
|
134
|
+
|| (value.version !== LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION && value.version !== SIGNED_EVIDENCE_PACKAGE_VERSION)
|
|
138
135
|
|| !artifact
|
|
139
136
|
|| !signature
|
|
140
137
|
|| !hasExactKeys(signature, SIGNATURE_KEYS)
|
|
141
138
|
|| signature.algorithm !== EVIDENCE_SIGNATURE_ALGORITHM
|
|
142
|
-
|| signature.canonicalization !== EVIDENCE_SIGNATURE_CANONICALIZATION
|
|
143
139
|
|| typeof signature.publicKeySpkiDerBase64 !== 'string'
|
|
144
140
|
|| signature.publicKeySpkiDerBase64.length > 4096
|
|
145
141
|
|| !BASE64_RE.test(signature.publicKeySpkiDerBase64)
|
|
@@ -150,7 +146,17 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
150
146
|
|| !SHA256_RE.test(signature.keyIdSha256)) {
|
|
151
147
|
throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
|
|
152
148
|
}
|
|
153
|
-
|
|
149
|
+
let canonicalization;
|
|
150
|
+
try {
|
|
151
|
+
canonicalization = resolveArtifactCanonicalization(value.version, signature.canonicalization, [
|
|
152
|
+
{ version: LEGACY_SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V1, explicit: true },
|
|
153
|
+
{ version: SIGNED_EVIDENCE_PACKAGE_VERSION, canonicalization: SORTED_JSON_V2, explicit: true },
|
|
154
|
+
], 'Signed evidence package');
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
throw new TypeError('Signed evidence package has an unsupported or malformed envelope.');
|
|
158
|
+
}
|
|
159
|
+
const signedAt = canonicalTimestamp(value.signedAt);
|
|
154
160
|
const publicDer = Buffer.from(signature.publicKeySpkiDerBase64, 'base64');
|
|
155
161
|
if (keyId(publicDer) !== signature.keyIdSha256) {
|
|
156
162
|
throw new TypeError('Signed evidence package public-key identifier does not match its embedded key.');
|
|
@@ -158,16 +164,20 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
158
164
|
const embeddedPublicKey = importPublicKey(publicDer, 'Embedded evidence-signing public key');
|
|
159
165
|
const payload = {
|
|
160
166
|
schema: SIGNED_EVIDENCE_PACKAGE_SCHEMA,
|
|
161
|
-
version:
|
|
167
|
+
version: value.version,
|
|
162
168
|
signedAt,
|
|
163
169
|
artifact,
|
|
164
170
|
};
|
|
165
171
|
const signatureBytes = Buffer.from(signature.valueBase64, 'base64');
|
|
166
172
|
if (signatureBytes.length !== 64
|
|
167
|
-
|| !verify(null, payloadBytes(payload), embeddedPublicKey, signatureBytes)) {
|
|
173
|
+
|| !verify(null, payloadBytes(payload, canonicalization), embeddedPublicKey, signatureBytes)) {
|
|
168
174
|
throw new TypeError('Signed evidence package failed Ed25519 verification.');
|
|
169
175
|
}
|
|
170
|
-
|
|
176
|
+
let artifactVerification = null;
|
|
177
|
+
try {
|
|
178
|
+
artifactVerification = await verifyOfflineArtifact(JSON.stringify(artifact));
|
|
179
|
+
}
|
|
180
|
+
catch { /* Signature validity remains independently reportable. */ }
|
|
171
181
|
let publicKeyMatched = null;
|
|
172
182
|
if (trustedPublicKeyPem) {
|
|
173
183
|
const trustedDer = spkiDer(importPublicKey(trustedPublicKeyPem, 'Trusted public key file'));
|
|
@@ -176,24 +186,41 @@ export async function verifyEvidencePackageSignature(raw, trustedPublicKeyPem) {
|
|
|
176
186
|
throw new TypeError('Signed evidence package does not match the trusted public key.');
|
|
177
187
|
}
|
|
178
188
|
return Object.freeze({
|
|
179
|
-
schema:
|
|
180
|
-
version:
|
|
181
|
-
valid: true,
|
|
189
|
+
schema: EVIDENCE_SIGNATURE_VERIFICATION_SCHEMA,
|
|
190
|
+
version: EVIDENCE_SIGNATURE_VERIFICATION_VERSION,
|
|
182
191
|
state: 'signature_valid',
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
signature: Object.freeze({
|
|
193
|
+
state: 'valid',
|
|
194
|
+
signerTrust: trustedPublicKeyPem ? 'trusted_key' : 'embedded_key_only',
|
|
195
|
+
signedAt,
|
|
196
|
+
keyIdSha256: signature.keyIdSha256,
|
|
197
|
+
publicKeyMatched,
|
|
198
|
+
}),
|
|
187
199
|
artifact: Object.freeze({
|
|
188
|
-
schema: artifactVerification
|
|
189
|
-
|
|
190
|
-
|
|
200
|
+
schema: artifactVerification?.artifact.schema
|
|
201
|
+
?? (typeof artifact.schema === 'string' && /^[a-z0-9.-]{1,160}$/u.test(artifact.schema) ? artifact.schema : null),
|
|
202
|
+
version: artifactVerification?.artifact.version
|
|
203
|
+
?? (Number.isSafeInteger(artifact.version ?? artifact.schemaVersion)
|
|
204
|
+
&& Number(artifact.version ?? artifact.schemaVersion) >= 1
|
|
205
|
+
&& Number(artifact.version ?? artifact.schemaVersion) <= 1_000
|
|
206
|
+
? Number(artifact.version ?? artifact.schemaVersion)
|
|
207
|
+
: null),
|
|
208
|
+
kind: artifactVerification?.artifact.kind ?? 'unverified_artifact',
|
|
209
|
+
assurance: Object.freeze({
|
|
210
|
+
state: artifactVerification?.state ?? 'not_verified',
|
|
211
|
+
structure: artifactVerification?.checks.structure ?? 'not_checked',
|
|
212
|
+
contentIntegrity: artifactVerification?.checks.contentIntegrity ?? 'not_checked',
|
|
213
|
+
contentIntegrityScope: artifactVerification?.checks.contentIntegrityScope ?? 'not_checked',
|
|
214
|
+
}),
|
|
191
215
|
}),
|
|
192
216
|
limitations: Object.freeze([
|
|
193
217
|
trustedPublicKeyPem
|
|
194
218
|
? 'The signature and supplied public key match; recipient trust still depends on obtaining that public key through an authenticated channel.'
|
|
195
219
|
: 'The signature is internally valid for the embedded public key, but signer identity was not authenticated because no trusted public key was supplied.',
|
|
196
220
|
'The signature authenticates the canonical sorted-JSON content, not the original whitespace, indentation, or object-key order of the serialised file.',
|
|
221
|
+
...(artifactVerification
|
|
222
|
+
? [`Embedded-artifact assurance is reported separately as ${artifactVerification.state}; it does not change the cryptographic signature result.`]
|
|
223
|
+
: ['The embedded artefact did not pass a supported local assurance contract. The cryptographic signature remains valid for its bytes, but no structure or inner-integrity claim is made.']),
|
|
197
224
|
'A valid signature proves the canonical package content has not changed since signing; it does not establish that the retained observations or analyst statements are accurate.',
|
|
198
225
|
'WHOISleuth does not generate, store, recover, rotate, or publish signing keys.',
|
|
199
226
|
]),
|
|
@@ -203,11 +230,12 @@ export function formatEvidenceSignatureVerification(report) {
|
|
|
203
230
|
const lines = [
|
|
204
231
|
'WHOISleuth evidence signature verification',
|
|
205
232
|
`State: ${report.state}`,
|
|
206
|
-
`Signer trust: ${report.signerTrust}`,
|
|
207
|
-
`Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
|
|
208
|
-
`
|
|
209
|
-
`
|
|
210
|
-
`
|
|
233
|
+
`Signer trust: ${report.signature.signerTrust}`,
|
|
234
|
+
`Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'unrecognised'} v${report.artifact.version ?? 'unrecognised'}`,
|
|
235
|
+
`Artifact assurance: ${report.artifact.assurance.state}`,
|
|
236
|
+
`Signed: ${report.signature.signedAt}`,
|
|
237
|
+
`Key ID: sha256:${report.signature.keyIdSha256}`,
|
|
238
|
+
`Trusted public key: ${report.signature.publicKeyMatched === null ? 'not supplied' : 'matched'}`,
|
|
211
239
|
];
|
|
212
240
|
for (const limitation of report.limitations)
|
|
213
241
|
lines.push(`Limitation: ${limitation}`);
|
package/cli/export-evidence.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import { projectCliLookupComparisonInput } from "./compare.mjs";
|
|
3
2
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { validateLookupEvidenceArtifactStructure } from "./artifact-structure.mjs";
|
|
4
|
+
import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
|
|
5
|
+
import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, serializeLookupEvidence, } from "../lib/evidence-export.mjs";
|
|
6
|
+
const APPLICATION_VERSION = WHOISLEUTH_APPLICATION_VERSION;
|
|
6
7
|
function objectOrNull(value) {
|
|
7
8
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
8
9
|
}
|
|
@@ -27,9 +28,15 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
27
28
|
|| result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
28
29
|
throw new TypeError('Lookup evidence builder returned an unsupported report contract.');
|
|
29
30
|
}
|
|
31
|
+
validateLookupEvidenceArtifactStructure(result);
|
|
32
|
+
serializeLookupEvidence(result);
|
|
30
33
|
return result;
|
|
31
34
|
}
|
|
32
35
|
function formatCliEvidenceExport(document, compact = false) {
|
|
33
|
-
|
|
36
|
+
const output = `${serializeLookupEvidence(document, !compact)}\n`;
|
|
37
|
+
if (new TextEncoder().encode(output).byteLength > LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES) {
|
|
38
|
+
throw new TypeError('Lookup evidence exceeds the 5 MiB portable file limit.');
|
|
39
|
+
}
|
|
40
|
+
return output;
|
|
34
41
|
}
|
|
35
42
|
export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -26,8 +26,8 @@ function renderRegistrarComparison(comparison) {
|
|
|
26
26
|
: '';
|
|
27
27
|
return `<div class="table-scroll"><table><caption>Normalized registry and registrar RDAP publication comparison</caption><thead><tr><th scope="col">Field</th><th scope="col">Result</th><th scope="col">Registry RDAP</th><th scope="col">Registrar RDAP</th></tr></thead><tbody>${rows}</tbody></table></div>${omitted}`;
|
|
28
28
|
}
|
|
29
|
-
function formatLookupEvidenceHtml(document) {
|
|
30
|
-
const report = buildLookupEvidenceReport(document);
|
|
29
|
+
function formatLookupEvidenceHtml(document, options = {}) {
|
|
30
|
+
const report = buildLookupEvidenceReport(document, options);
|
|
31
31
|
const title = `Lookup evidence report: ${report.title}`;
|
|
32
32
|
return `<!doctype html>
|
|
33
33
|
<html lang="en">
|
|
@@ -60,7 +60,7 @@ function formatLookupEvidenceHtml(document) {
|
|
|
60
60
|
<section class="block"><h2>Network evidence</h2>${renderGroups(report.networkGroups)}</section>
|
|
61
61
|
<section class="block"><h2>Collection diagnostics</h2>${renderFields(report.diagnostics)}</section>
|
|
62
62
|
<section class="block"><h2>Limitations</h2><ul>${report.limitations.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></section>
|
|
63
|
-
<footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included
|
|
63
|
+
<footer>Offline, self-contained report. No scripts, forms, external resources, or active links are included.${report.attribution ? `<br>${escapeHtml(report.attribution)}` : ''}</footer>
|
|
64
64
|
</main>
|
|
65
65
|
</body>
|
|
66
66
|
</html>
|
|
@@ -271,6 +271,9 @@ function formatTerminalRegistrySupport(document) {
|
|
|
271
271
|
`WHOIS scope ${supportLabel(whois.queryScope)}`,
|
|
272
272
|
`WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
|
|
273
273
|
`WHOIS parser ${supportLabel(whois.parserProfile)}`,
|
|
274
|
+
...(typeof profile.officialLookupUrl === 'string' && profile.officialLookupUrl
|
|
275
|
+
? [`Official lookup ${safeTerminalValue(profile.officialLookupUrl)}`]
|
|
276
|
+
: []),
|
|
274
277
|
`Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
|
|
275
278
|
];
|
|
276
279
|
for (const file of files)
|
package/cli/input.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
3
|
+
import { CliUsageError } from "./errors.mjs";
|
|
4
|
+
function abortError(signal) {
|
|
5
|
+
return signal.reason instanceof Error
|
|
6
|
+
? signal.reason
|
|
7
|
+
: new DOMException('The operation was aborted.', 'AbortError');
|
|
8
|
+
}
|
|
9
|
+
export async function readCliTextInput(source, stdin, options) {
|
|
10
|
+
if (options.signal?.aborted)
|
|
11
|
+
throw abortError(options.signal);
|
|
12
|
+
if (source) {
|
|
13
|
+
try {
|
|
14
|
+
return await readBoundedRegularTextFile(source, {
|
|
15
|
+
maximumBytes: options.maximumBytes,
|
|
16
|
+
label: options.label,
|
|
17
|
+
allowSymbolicLink: true,
|
|
18
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (options.signal?.aborted)
|
|
23
|
+
throw abortError(options.signal);
|
|
24
|
+
if (error instanceof CliUsageError)
|
|
25
|
+
throw error;
|
|
26
|
+
const message = error instanceof Error ? error.message : `${options.label} could not be read.`;
|
|
27
|
+
throw new CliUsageError(message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!stdin || stdin.isTTY)
|
|
31
|
+
return '';
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
const chunks = [];
|
|
34
|
+
const onAbort = () => {
|
|
35
|
+
if ('destroy' in stdin && typeof stdin.destroy === 'function')
|
|
36
|
+
stdin.destroy(abortError(options.signal));
|
|
37
|
+
};
|
|
38
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of stdin) {
|
|
41
|
+
if (options.signal?.aborted)
|
|
42
|
+
throw abortError(options.signal);
|
|
43
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
44
|
+
bytes += buffer.length;
|
|
45
|
+
if (bytes > options.maximumBytes) {
|
|
46
|
+
throw new CliUsageError(`${options.label} is limited to ${options.maximumBytes} bytes.`);
|
|
47
|
+
}
|
|
48
|
+
chunks.push(buffer);
|
|
49
|
+
}
|
|
50
|
+
if (options.signal?.aborted)
|
|
51
|
+
throw abortError(options.signal);
|
|
52
|
+
return Buffer.concat(chunks, bytes).toString('utf8');
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { INTERCHANGE_ARTIFACT_CONTRACTS, } from "../lib/interchange-fidelity-registry.mjs";
|
|
3
|
+
import { offlineArtifactSatisfiesAssurance, verifyOfflineArtifact, } from "./artifact-verify.mjs";
|
|
4
|
+
import { mergeBrandProfiles } from "../frontend/src/lib/analysis/brand-profile-model.js";
|
|
5
|
+
import { parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
6
|
+
export const INTERCHANGE_FIDELITY_REPORT_SCHEMA = 'whoisleuth.interchange-fidelity-report';
|
|
7
|
+
export const INTERCHANGE_FIDELITY_REPORT_VERSION = 2;
|
|
8
|
+
export const MAX_INTERCHANGE_REPORT_BYTES = 15 * 1024 * 1024;
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
function parseInput(raw) {
|
|
15
|
+
if (typeof raw !== 'string')
|
|
16
|
+
throw new TypeError('Interchange input must be UTF-8 JSON text.');
|
|
17
|
+
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
18
|
+
if (bytes < 1 || bytes > MAX_INTERCHANGE_REPORT_BYTES) {
|
|
19
|
+
throw new TypeError(`Interchange input must be between 1 byte and ${MAX_INTERCHANGE_REPORT_BYTES} bytes.`);
|
|
20
|
+
}
|
|
21
|
+
return parseBoundedJsonObject(raw.replace(/^\uFEFF/u, ''), {
|
|
22
|
+
label: 'Interchange input',
|
|
23
|
+
maximumBytes: MAX_INTERCHANGE_REPORT_BYTES,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function nestedRecord(value, path) {
|
|
27
|
+
let current = value;
|
|
28
|
+
for (const part of path)
|
|
29
|
+
current = record(current?.[part]);
|
|
30
|
+
return current;
|
|
31
|
+
}
|
|
32
|
+
function identify(value) {
|
|
33
|
+
for (const contract of INTERCHANGE_ARTIFACT_CONTRACTS) {
|
|
34
|
+
const container = nestedRecord(value, contract.nestedSchemaPath);
|
|
35
|
+
if (container?.schema !== contract.schema)
|
|
36
|
+
continue;
|
|
37
|
+
const declared = container[contract.versionField];
|
|
38
|
+
return {
|
|
39
|
+
contract,
|
|
40
|
+
version: Number.isSafeInteger(declared) && Number(declared) > 0 && Number(declared) <= 1000
|
|
41
|
+
? Number(declared)
|
|
42
|
+
: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function timestamp(value) {
|
|
48
|
+
if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
49
|
+
throw new TypeError('Interchange report time is invalid.');
|
|
50
|
+
}
|
|
51
|
+
const parsed = Date.parse(value);
|
|
52
|
+
if (!Number.isFinite(parsed))
|
|
53
|
+
throw new TypeError('Interchange report time is invalid.');
|
|
54
|
+
return new Date(parsed).toISOString();
|
|
55
|
+
}
|
|
56
|
+
function validateBrandProfileExport(value) {
|
|
57
|
+
const profiles = value.profiles;
|
|
58
|
+
if (!Array.isArray(profiles) || profiles.length > 100) {
|
|
59
|
+
throw new TypeError('Brand Profile export does not contain a bounded profiles collection.');
|
|
60
|
+
}
|
|
61
|
+
if (typeof value.exportedAt !== 'string' || value.exportedAt.length > 64 || !Number.isFinite(Date.parse(value.exportedAt))) {
|
|
62
|
+
throw new TypeError('Brand Profile export time is invalid.');
|
|
63
|
+
}
|
|
64
|
+
let nextId = 0;
|
|
65
|
+
const result = mergeBrandProfiles([], value, {
|
|
66
|
+
nowIso: new Date(Date.parse(value.exportedAt)).toISOString(),
|
|
67
|
+
makeId: () => `interchange-profile-${++nextId}`,
|
|
68
|
+
});
|
|
69
|
+
return Object.freeze({ accepted: result.profiles.length, skipped: result.skipped });
|
|
70
|
+
}
|
|
71
|
+
export async function buildInterchangeFidelityReport(raw, options = {}) {
|
|
72
|
+
const inputBytes = Buffer.byteLength(raw, 'utf8');
|
|
73
|
+
const value = parseInput(raw);
|
|
74
|
+
const identified = identify(value);
|
|
75
|
+
const generatedAt = timestamp(options.generatedAt ?? new Date().toISOString());
|
|
76
|
+
if (!identified)
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
|
|
79
|
+
version: INTERCHANGE_FIDELITY_REPORT_VERSION,
|
|
80
|
+
generatedAt,
|
|
81
|
+
recognised: false,
|
|
82
|
+
artifact: Object.freeze({ id: null, schema: null, version: null, versionSupported: false }),
|
|
83
|
+
verification: Object.freeze({ state: 'not_verified', requiredAssurance: null, assuranceSatisfied: false }),
|
|
84
|
+
compatibility: Object.freeze({
|
|
85
|
+
browser: null,
|
|
86
|
+
cli: null,
|
|
87
|
+
fidelity: 'not_verified',
|
|
88
|
+
preservedFieldGroups: Object.freeze([]),
|
|
89
|
+
excludedFieldGroups: Object.freeze([]),
|
|
90
|
+
futureVersionBehaviour: null,
|
|
91
|
+
fullyImportable: null,
|
|
92
|
+
}),
|
|
93
|
+
summary: Object.freeze({
|
|
94
|
+
inputBytes, recordCount: null, sectionCount: null, encryptedContentVerified: null,
|
|
95
|
+
acceptedRecordCount: null, skippedRecordCount: null, unsupportedSectionCount: null,
|
|
96
|
+
blockedSectionCount: null, prunedRecordCount: null,
|
|
97
|
+
}),
|
|
98
|
+
limitations: Object.freeze([
|
|
99
|
+
'The file did not match a registered interchange contract. Its supplied schema text and contents are not echoed.',
|
|
100
|
+
'This report makes no request and does not establish that evidence is accurate, current, complete, or safe to share.',
|
|
101
|
+
]),
|
|
102
|
+
});
|
|
103
|
+
const { contract, version } = identified;
|
|
104
|
+
const supported = version !== null && contract.versions.includes(version);
|
|
105
|
+
let verificationState = supported ? 'not_verified' : 'unsupported_version';
|
|
106
|
+
let assuranceSatisfied = false;
|
|
107
|
+
let recordCount = null;
|
|
108
|
+
let sectionCount = null;
|
|
109
|
+
let encryptedContentVerified = contract.id === 'encrypted_workspace' ? false : null;
|
|
110
|
+
let acceptedRecordCount = null;
|
|
111
|
+
let skippedRecordCount = null;
|
|
112
|
+
let unsupportedSectionCount = null;
|
|
113
|
+
let blockedSectionCount = null;
|
|
114
|
+
let prunedRecordCount = null;
|
|
115
|
+
let fullyImportable = null;
|
|
116
|
+
if (supported && contract.fidelity !== 'unsupported') {
|
|
117
|
+
try {
|
|
118
|
+
if (contract.id === 'brand_profiles') {
|
|
119
|
+
const validation = validateBrandProfileExport(value);
|
|
120
|
+
recordCount = validation.accepted;
|
|
121
|
+
acceptedRecordCount = validation.accepted;
|
|
122
|
+
skippedRecordCount = validation.skipped;
|
|
123
|
+
verificationState = validation.skipped === 0 ? 'structure_valid' : 'not_verified';
|
|
124
|
+
assuranceSatisfied = validation.skipped === 0 && contract.requiredAssurance === 'structure';
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const verification = await verifyOfflineArtifact(raw, { passphrase: options.passphrase ?? null });
|
|
128
|
+
verificationState = verification.state;
|
|
129
|
+
assuranceSatisfied = contract.requiredAssurance === 'whole_integrity'
|
|
130
|
+
? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
|
|
131
|
+
: contract.requiredAssurance === 'structure'
|
|
132
|
+
? offlineArtifactSatisfiesAssurance(verification, 'structure')
|
|
133
|
+
: contract.requiredAssurance === 'authenticated_whole_integrity'
|
|
134
|
+
? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
|
|
135
|
+
&& verification.checks.authenticatedEncryption === 'verified'
|
|
136
|
+
: false;
|
|
137
|
+
recordCount = verification.summary.recordCount;
|
|
138
|
+
sectionCount = verification.summary.sectionCount;
|
|
139
|
+
const unsupportedSections = verification.summary.unsupportedSectionCount ?? 0;
|
|
140
|
+
unsupportedSectionCount = unsupportedSections;
|
|
141
|
+
blockedSectionCount = verification.summary.blockedSectionCount ?? null;
|
|
142
|
+
skippedRecordCount = verification.summary.skippedRecordCount ?? skippedRecordCount;
|
|
143
|
+
prunedRecordCount = verification.summary.prunedRecordCount ?? null;
|
|
144
|
+
if (verification.summary.fullyImportable !== undefined
|
|
145
|
+
&& verification.summary.fullyImportable !== null) {
|
|
146
|
+
fullyImportable = assuranceSatisfied && verification.summary.fullyImportable;
|
|
147
|
+
}
|
|
148
|
+
if (contract.id === 'encrypted_workspace')
|
|
149
|
+
encryptedContentVerified = verification.state === 'verified'
|
|
150
|
+
&& verification.checks.authenticatedEncryption === 'verified';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
verificationState = 'not_verified';
|
|
155
|
+
assuranceSatisfied = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const fidelityEligible = assuranceSatisfied && fullyImportable !== false;
|
|
159
|
+
const fidelity = !supported
|
|
160
|
+
? 'unsupported'
|
|
161
|
+
: fidelityEligible
|
|
162
|
+
? contract.fidelity
|
|
163
|
+
: contract.fidelity === 'unsupported'
|
|
164
|
+
? 'unsupported'
|
|
165
|
+
: 'not_verified';
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
|
|
168
|
+
version: INTERCHANGE_FIDELITY_REPORT_VERSION,
|
|
169
|
+
generatedAt,
|
|
170
|
+
recognised: true,
|
|
171
|
+
artifact: Object.freeze({ id: contract.id, schema: contract.schema, version, versionSupported: supported }),
|
|
172
|
+
verification: Object.freeze({
|
|
173
|
+
state: verificationState,
|
|
174
|
+
requiredAssurance: contract.requiredAssurance,
|
|
175
|
+
assuranceSatisfied,
|
|
176
|
+
}),
|
|
177
|
+
compatibility: Object.freeze({
|
|
178
|
+
browser: contract.browser,
|
|
179
|
+
cli: contract.cli,
|
|
180
|
+
fidelity,
|
|
181
|
+
preservedFieldGroups: contract.preservedFieldGroups,
|
|
182
|
+
excludedFieldGroups: contract.excludedFieldGroups,
|
|
183
|
+
futureVersionBehaviour: contract.futureVersionBehaviour,
|
|
184
|
+
fullyImportable,
|
|
185
|
+
}),
|
|
186
|
+
summary: Object.freeze({
|
|
187
|
+
inputBytes, recordCount, sectionCount, encryptedContentVerified,
|
|
188
|
+
acceptedRecordCount, skippedRecordCount, unsupportedSectionCount,
|
|
189
|
+
blockedSectionCount, prunedRecordCount,
|
|
190
|
+
}),
|
|
191
|
+
limitations: Object.freeze([
|
|
192
|
+
'Compatibility describes declared field groups and supported operations, not byte-for-byte equality or evidence truth.',
|
|
193
|
+
'The report emits only registered metadata, counts, and fixed field-group identifiers. Targets, notes, contacts, passphrases, and evidence values are not included.',
|
|
194
|
+
'A verified integrity envelope detects file changes but does not establish author identity, source accuracy, currency, completeness, or sharing authority.',
|
|
195
|
+
]),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export function formatInterchangeFidelityReport(report) {
|
|
199
|
+
const browser = report.compatibility.browser;
|
|
200
|
+
const cli = report.compatibility.cli;
|
|
201
|
+
return [
|
|
202
|
+
'Interchange fidelity report',
|
|
203
|
+
`Recognised ${report.recognised ? 'yes' : 'no'}`,
|
|
204
|
+
`Artifact ${report.artifact.id ?? 'unrecognised'}`,
|
|
205
|
+
`Version ${report.artifact.version ?? 'unavailable'}${report.artifact.versionSupported ? '' : ' (unsupported)'}`,
|
|
206
|
+
`Verification ${report.verification.state}`,
|
|
207
|
+
`Assurance ${report.verification.requiredAssurance ?? 'unregistered'} · ${report.verification.assuranceSatisfied ? 'satisfied' : 'withheld'}`,
|
|
208
|
+
`Fidelity ${report.compatibility.fidelity}`,
|
|
209
|
+
...(report.compatibility.fullyImportable === null ? [] : [`Importability ${report.compatibility.fullyImportable ? 'complete' : 'partial'}`]),
|
|
210
|
+
`Browser import ${browser?.import ?? 'unknown'} · export ${browser?.export ?? 'unknown'}`,
|
|
211
|
+
`CLI read ${cli?.read ?? 'unknown'} · write ${cli?.write ?? 'unknown'} · verify ${cli?.verify ?? 'unknown'}`,
|
|
212
|
+
`Records ${report.summary.recordCount ?? 'not disclosed'}`,
|
|
213
|
+
`Sections ${report.summary.sectionCount ?? 'not disclosed'}`,
|
|
214
|
+
...(report.summary.acceptedRecordCount === null ? [] : [`Accepted ${report.summary.acceptedRecordCount}`]),
|
|
215
|
+
...(report.summary.skippedRecordCount === null ? [] : [`Skipped ${report.summary.skippedRecordCount}`]),
|
|
216
|
+
...(report.summary.unsupportedSectionCount === null ? [] : [`Unsupported ${report.summary.unsupportedSectionCount}`]),
|
|
217
|
+
...(report.summary.blockedSectionCount === null ? [] : [`Blocked ${report.summary.blockedSectionCount}`]),
|
|
218
|
+
...(report.summary.prunedRecordCount === null ? [] : [`Pruned ${report.summary.prunedRecordCount}`]),
|
|
219
|
+
'',
|
|
220
|
+
`Preserved ${report.compatibility.preservedFieldGroups.join(', ') || 'none declared'}`,
|
|
221
|
+
`Excluded ${report.compatibility.excludedFieldGroups.join(', ') || 'none declared'}`,
|
|
222
|
+
'',
|
|
223
|
+
...report.limitations.map((item) => `Limitation: ${item}`),
|
|
224
|
+
'',
|
|
225
|
+
].join('\n');
|
|
226
|
+
}
|