@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,16 +1,11 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { RELATIONSHIP_EVIDENCE_VERSION } from "../contracts/offline-comparison.mjs";
4
+ import { RELATIONSHIP_TYPES, qualifyRelationshipSources } from "../comparison/relationship-provenance.mjs";
3
5
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
6
  import { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
7
  export { MAX_RELATIONSHIP_OBSERVATIONS, MAX_RELATIONSHIP_OBSERVATION_DOMAINS, MAX_RELATIONSHIP_OBSERVATION_INPUTS, MAX_RELATIONSHIP_OBSERVATION_LIMITATIONS, MAX_RELATIONSHIP_OBSERVATION_STORE_BYTES, MAX_RELATIONSHIP_OBSERVATION_VALUE_LENGTH, RELATIONSHIP_OBSERVATION_SCHEMA, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
6
- export const RELATIONSHIP_OBSERVATION_TYPES = Object.freeze([
7
- 'nameserver_set',
8
- 'ip_address',
9
- 'certificate',
10
- 'tracking_identifier',
11
- 'favicon',
12
- 'official_asset',
13
- ]);
8
+ export const RELATIONSHIP_OBSERVATION_TYPES = RELATIONSHIP_TYPES;
14
9
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
15
10
  const SAFE_ID_RE = /^relationship-[a-z0-9]{1,16}-[a-z0-9]{1,16}$/u;
16
11
  const SHA256_RE = /^[a-f0-9]{64}$/iu;
@@ -205,7 +200,7 @@ export function relationshipObservationId(raw) {
205
200
  }
206
201
  export function relationshipObservationStoreVersion(raw) {
207
202
  const value = record(raw);
208
- return positiveInteger(value?.version);
203
+ return typeof value?.version === 'number' && Number.isSafeInteger(value.version) && value.version > 0 ? value.version : null;
209
204
  }
210
205
  function observationList(raw) {
211
206
  if (Array.isArray(raw))
@@ -213,7 +208,7 @@ function observationList(raw) {
213
208
  const value = record(raw);
214
209
  return Array.isArray(value?.observations) ? value.observations : [];
215
210
  }
216
- export function normalizeRelationshipObservation(raw) {
211
+ export function normalizeRelationshipObservation(raw, sourceStoreVersion = RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
217
212
  const value = record(raw);
218
213
  if (!value || typeof value.type !== 'string' || !TYPES.has(value.type))
219
214
  return null;
@@ -230,8 +225,16 @@ export function normalizeRelationshipObservation(raw) {
230
225
  const id = observationId(canonical);
231
226
  const observedAt = timestamp(value.observedAt);
232
227
  const retainedAt = timestamp(value.retainedAt);
233
- if (!observedAt || !retainedAt)
228
+ if (!retainedAt || (value.observedAt !== null && !observedAt))
234
229
  return null;
230
+ const sourceVersion = positiveInteger(value.sourceVersion) || 1;
231
+ if (value.sourceVersion !== undefined && !positiveInteger(value.sourceVersion))
232
+ throw new TypeError('This retained relationship uses an unsupported source-evidence version; no evidence was interpreted.');
233
+ if (sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
234
+ throw new TypeError('This retained relationship uses a newer source-evidence version; no evidence was interpreted.');
235
+ if (sourceStoreVersion === 1 && sourceVersion > 2)
236
+ throw new TypeError('Relationship schema 1 cannot contain newer source evidence; no evidence was interpreted.');
237
+ const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? value.sourceEvidence : [], domains, { truncated: value.truncated === true, type });
235
238
  return {
236
239
  id,
237
240
  type,
@@ -243,11 +246,14 @@ export function normalizeRelationshipObservation(raw) {
243
246
  description: metadata.description,
244
247
  classification: 'derived',
245
248
  source: 'bulk_relationship_analysis',
246
- sourceVersion: positiveInteger(value.sourceVersion) || 1,
247
- observedAt,
249
+ sourceVersion,
250
+ // Legacy scan timestamps remain available, but cannot qualify missing
251
+ // contributing-source timestamps or turn the old complete flag into proof.
252
+ observedAt: qualified.observedAt ?? (observedAt || null),
253
+ sourceEvidence: qualified.sourceEvidence,
248
254
  retainedAt,
249
- complete: value.complete === true,
250
- truncated: value.truncated === true
255
+ complete: value.complete === true && qualified.complete,
256
+ truncated: qualified.truncated
251
257
  || (Array.isArray(value.domains) && value.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
252
258
  limitations: observationLimitations(value.limitations),
253
259
  };
@@ -255,9 +261,13 @@ export function normalizeRelationshipObservation(raw) {
255
261
  export function normalizeRelationshipObservationStore(raw) {
256
262
  assertWorkspaceInputGraph(raw, 'Relationship-observation store');
257
263
  assertWorkspaceDeclaredVersion(raw, 'Relationship-observation store');
264
+ const sourceStoreVersion = relationshipObservationStoreVersion(raw) ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION;
265
+ if (sourceStoreVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
266
+ throw new TypeError(`This relationship-observation store uses newer schema ${sourceStoreVersion}; no evidence was interpreted.`);
267
+ }
258
268
  const byId = new Map();
259
269
  for (const candidate of observationList(raw).slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
260
- const observation = normalizeRelationshipObservation(candidate);
270
+ const observation = normalizeRelationshipObservation(candidate, sourceStoreVersion);
261
271
  if (!observation)
262
272
  continue;
263
273
  const existing = byId.get(observation.id);
@@ -283,11 +293,16 @@ export function createRelationshipObservation(raw, options = {}) {
283
293
  throw new Error('That relationship does not contain a supported bounded value and at least one valid domain.');
284
294
  }
285
295
  const now = new Date().toISOString();
286
- const observedAt = Object.hasOwn(options, 'observedAt') ? timestamp(options.observedAt) : now;
296
+ const sourceVersion = Object.hasOwn(options, 'sourceVersion') ? positiveInteger(options.sourceVersion) : RELATIONSHIP_EVIDENCE_VERSION;
297
+ if (!sourceVersion || sourceVersion > RELATIONSHIP_EVIDENCE_VERSION)
298
+ throw new TypeError('This relationship needs a supported source-evidence version.');
299
+ const qualified = qualifyRelationshipSources(sourceVersion === RELATIONSHIP_EVIDENCE_VERSION ? raw.sourceEvidence : [], domains, { truncated: options.truncated === true, type });
300
+ // Older callers can supply their original scan timestamp. It does not
301
+ // qualify the missing source metadata, and an explicit current projection
302
+ // never substitutes that timestamp for unknown contributing-source times.
303
+ const observedAt = qualified.observedAt
304
+ ?? (!Object.hasOwn(raw, 'sourceEvidence') ? timestamp(options.observedAt) || null : null);
287
305
  const retainedAt = timestamp(options.retainedAt) || now;
288
- if (!observedAt) {
289
- throw new Error('A relationship observation needs the contributing scan observation time; an unknown time is not replaced with the retention time.');
290
- }
291
306
  const canonical = canonicalIdentity(type, normalizedValue, domains);
292
307
  const metadata = TYPE_METADATA[type];
293
308
  return {
@@ -301,11 +316,12 @@ export function createRelationshipObservation(raw, options = {}) {
301
316
  description: metadata.description,
302
317
  classification: 'derived',
303
318
  source: 'bulk_relationship_analysis',
304
- sourceVersion: positiveInteger(options.sourceVersion) || 1,
319
+ sourceVersion,
305
320
  observedAt,
321
+ sourceEvidence: qualified.sourceEvidence,
306
322
  retainedAt,
307
- complete: options.complete === true,
308
- truncated: options.truncated === true
323
+ complete: options.complete !== false && qualified.complete,
324
+ truncated: qualified.truncated
309
325
  || (Array.isArray(raw.domains) && raw.domains.length > MAX_RELATIONSHIP_OBSERVATION_DOMAINS),
310
326
  limitations: observationLimitations(options.limitations),
311
327
  };
@@ -359,12 +375,11 @@ export function deleteRelationshipObservation(rawStore, id) {
359
375
  export function mergeRelationshipObservations(localRaw, importedRaw) {
360
376
  assertWorkspaceInputGraph(localRaw, 'Local relationship-observation store');
361
377
  assertWorkspaceInputGraph(importedRaw, 'Imported relationship-observation document');
362
- assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
378
+ const importedVersion = assertWorkspacePortableVersion(importedRaw, RELATIONSHIP_OBSERVATION_SCHEMA_VERSION, 'Imported relationship-observation document');
363
379
  const importedRecord = record(importedRaw);
364
380
  if (importedRecord?.schema !== undefined && importedRecord.schema !== RELATIONSHIP_OBSERVATION_SCHEMA) {
365
381
  throw new Error('This JSON section is not a WHOISleuth relationship-observation export.');
366
382
  }
367
- const importedVersion = relationshipObservationStoreVersion(importedRaw);
368
383
  if (importedVersion !== null && importedVersion > RELATIONSHIP_OBSERVATION_SCHEMA_VERSION) {
369
384
  throw new Error(`This relationship-observation section uses newer schema ${importedVersion}. Update the app before importing it.`);
370
385
  }
@@ -375,7 +390,7 @@ export function mergeRelationshipObservations(localRaw, importedRaw) {
375
390
  let updated = 0;
376
391
  let skipped = Math.max(0, importedInput.length - MAX_RELATIONSHIP_OBSERVATION_INPUTS);
377
392
  for (const candidate of importedInput.slice(0, MAX_RELATIONSHIP_OBSERVATION_INPUTS)) {
378
- const observation = normalizeRelationshipObservation(candidate);
393
+ const observation = normalizeRelationshipObservation(candidate, importedVersion ?? RELATIONSHIP_OBSERVATION_SCHEMA_VERSION);
379
394
  if (!observation) {
380
395
  skipped += 1;
381
396
  continue;
@@ -6,6 +6,7 @@ import { explainRiskScore, normalizeRiskModelVersion } from "../../lib/risk-scor
6
6
  import { HTTP_SECURITY_HEADER_TOKENS, normalizeHttpSummary } from "../cases/http-summary.mjs";
7
7
  import { normalizeDomain } from "../cases/case-model.mjs";
8
8
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
9
+ import { registryDateIso } from "../evidence/registry-dates.mjs";
9
10
  import { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
10
11
  export { MAX_WATCHLIST_CHANGES_PER_EVENT, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS, MAX_WATCHLIST_HISTORY_EVENTS, MAX_WATCHLIST_INPUT_RECORDS, MAX_WATCHLIST_MUTATION_TYPES, MAX_WATCHLIST_NAMESERVERS, } from "../contracts/workspace-portability.mjs";
11
12
  const MAX_TEXT_LENGTH = 300;
@@ -155,8 +156,7 @@ function boundedInteger(value, maximum, fallback = 0) {
155
156
  function normalizeDateValue(value) {
156
157
  if (typeof value !== 'string' || value.length > MAX_TIMESTAMP_LENGTH || CONTROL_RE.test(value))
157
158
  return null;
158
- const parsed = Date.parse(value);
159
- return Number.isFinite(parsed) ? new Date(parsed).toISOString().slice(0, 10) : null;
159
+ return registryDateIso(value)?.slice(0, 10) ?? null;
160
160
  }
161
161
  function inferredScanDepth(record) {
162
162
  if (record.scanDepth === 'deep' || record.scanDepth === 'fast')
@@ -109,15 +109,21 @@ export function mergeWatchlistStores(localRaw, importedRaw) {
109
109
  skipped++;
110
110
  continue;
111
111
  }
112
- if (Object.prototype.hasOwnProperty.call(local, name))
112
+ const normalized = normalizeWatchlistEntry(entry);
113
+ if (Object.prototype.hasOwnProperty.call(local, name)) {
114
+ if (normalized.updatedAt <= local[name].updatedAt) {
115
+ skipped++;
116
+ continue;
117
+ }
113
118
  updated++;
119
+ }
114
120
  else if (Object.keys(local).length >= MAX_WATCHLISTS) {
115
121
  skipped++;
116
122
  continue;
117
123
  }
118
124
  else
119
125
  added++;
120
- defineEntry(local, name, normalizeWatchlistEntry(entry));
126
+ defineEntry(local, name, normalized);
121
127
  }
122
128
  return { watchlists: local, added, updated, skipped };
123
129
  }
@@ -1,7 +1,8 @@
1
1
  import { normalizeDomain } from "../cases/case-model.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { validWebObservationMode } from "../evidence/lookup-target.mjs";
3
4
  import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
4
- import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
+ import { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, PAGE_FINGERPRINT_PARSERS, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
5
6
  export { MAX_WEBSITE_SNAPSHOTS, MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN, MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES, MAX_WEBSITE_SNAPSHOT_STORE_BYTES, SUPPORTED_WEBSITE_SNAPSHOT_SCHEMA_VERSIONS, WEBSITE_SNAPSHOT_SCHEMA, WEBSITE_SNAPSHOT_SCHEMA_VERSION, } from "../contracts/workspace-portability.mjs";
6
7
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
7
8
  const DIGEST_RE = /^[a-f0-9]{16,128}$/iu;
@@ -10,6 +11,10 @@ const SERIAL_RE = /^[a-f0-9]{1,128}$/iu;
10
11
  const SNAPSHOT_TECHNOLOGY_ROLES = new Set([
11
12
  'observed_edge', 'application_platform', 'framework_runtime', 'embedded_dependency',
12
13
  ]);
14
+ export const WEBSITE_SNAPSHOT_COLLECTION_LIMITS = Object.freeze({
15
+ technologies: 40, posture: 40, sources: 16, dependencies: 20,
16
+ resourceHosts: 30, trackingIdentifiers: 30, formActionOrigins: 20,
17
+ });
13
18
  function record(value) {
14
19
  return ordinaryWorkspaceRecord(value, 'Website-snapshot input');
15
20
  }
@@ -67,18 +72,26 @@ function profileVersion(value) {
67
72
  ? Number(value)
68
73
  : null;
69
74
  }
70
- function profileProvenance(value, legacy = false) {
75
+ function profileProvenance(value, sourceVersion) {
76
+ const legacy = sourceVersion === 4;
71
77
  if (legacy) {
72
78
  return {
73
79
  technology: { version: null, state: 'legacy_unknown' },
74
80
  securityPosture: { version: null, state: 'legacy_unknown' },
81
+ pageFingerprint: { version: 1, state: 'known' },
75
82
  };
76
83
  }
77
84
  const item = record(value);
78
85
  const technologyItem = record(item?.technology);
79
86
  const securityPostureItem = record(item?.securityPosture);
87
+ const pageFingerprintItem = record(item?.pageFingerprint);
80
88
  const technologyVersion = profileVersion(technologyItem?.version);
81
89
  const securityPostureVersion = profileVersion(securityPostureItem?.version);
90
+ // Exact published snapshot writers 4 and 5 used fingerprint algorithm 1.
91
+ // Unversioned records do not receive invented historical provenance.
92
+ const candidateFingerprintVersion = sourceVersion === 5 ? 1 : profileVersion(pageFingerprintItem?.version);
93
+ const pageFingerprintVersion = candidateFingerprintVersion !== null && Object.hasOwn(PAGE_FINGERPRINT_PARSERS, candidateFingerprintVersion)
94
+ ? candidateFingerprintVersion : null;
82
95
  return {
83
96
  technology: {
84
97
  version: technologyVersion,
@@ -88,6 +101,7 @@ function profileProvenance(value, legacy = false) {
88
101
  version: securityPostureVersion,
89
102
  state: securityPostureVersion === null ? 'legacy_unknown' : 'known',
90
103
  },
104
+ pageFingerprint: { version: pageFingerprintVersion, state: pageFingerprintVersion === null ? 'legacy_unknown' : 'known' },
91
105
  };
92
106
  }
93
107
  function posture(value) {
@@ -183,8 +197,8 @@ function origin(value) {
183
197
  }
184
198
  function identityValues(value) {
185
199
  const item = record(value);
186
- const resourceHosts = values(item?.resourceHosts, 30, (candidate) => normalizeDomain(candidate));
187
- const trackingIdentifiers = values(item?.trackingIdentifiers, 30, (candidate) => {
200
+ const resourceHosts = values(item?.resourceHosts, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.resourceHosts, (candidate) => normalizeDomain(candidate));
201
+ const trackingIdentifiers = values(item?.trackingIdentifiers, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.trackingIdentifiers, (candidate) => {
188
202
  const tracker = record(candidate);
189
203
  const type = text(tracker?.type, 40).toLowerCase();
190
204
  const trackerValue = text(tracker?.value, 64).toUpperCase();
@@ -192,7 +206,7 @@ function identityValues(value) {
192
206
  ? { type, value: trackerValue }
193
207
  : null;
194
208
  });
195
- const formActionOrigins = values(item?.formActionOrigins, 20, origin);
209
+ const formActionOrigins = values(item?.formActionOrigins, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.formActionOrigins, origin);
196
210
  return { resourceHosts, trackingIdentifiers, formActionOrigins };
197
211
  }
198
212
  export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
@@ -203,20 +217,24 @@ export function normalizeWebsiteProfileSnapshot(raw, sourceVersion) {
203
217
  const id = text(value?.id, 128);
204
218
  if (!domain || !observedAt || !savedAt || !id)
205
219
  return null;
220
+ const acceptsWebScope = sourceVersion === undefined || sourceVersion >= 6;
221
+ if (acceptsWebScope && !validWebObservationMode(value?.webObservationMode))
222
+ return null;
206
223
  return {
207
224
  id,
208
225
  domain,
226
+ ...(acceptsWebScope && value?.webObservationMode === 'selected_url' ? { webObservationMode: value.webObservationMode } : {}),
209
227
  observedAt,
210
228
  savedAt,
211
229
  complete: value?.complete === true,
212
230
  truncated: value?.truncated === true,
213
- profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion === 4),
214
- technologies: values(value?.technologies, 40, technology),
215
- posture: values(value?.posture, 40, posture),
231
+ profileProvenance: profileProvenance(value?.profileProvenance, sourceVersion),
232
+ technologies: values(value?.technologies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.technologies, technology),
233
+ posture: values(value?.posture, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.posture, posture),
216
234
  identity: identity(value?.identity),
217
235
  identityValues: identityValues(value?.identityValues),
218
- sources: values(value?.sources, 16, source),
219
- dependencies: values(value?.dependencies, 20, dependency),
236
+ sources: values(value?.sources, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.sources, source),
237
+ dependencies: values(value?.dependencies, WEBSITE_SNAPSHOT_COLLECTION_LIMITS.dependencies, dependency),
220
238
  certificate: certificate(value?.certificate),
221
239
  };
222
240
  }
@@ -366,6 +384,13 @@ export function websiteSnapshotComparisonEvidenceComplete(snapshot) {
366
384
  && websiteSnapshotFieldComplete(snapshot, 'dependency.CNAME')
367
385
  && (!snapshot.certificate || websiteSnapshotFieldComplete(snapshot, 'certificate.observation'));
368
386
  }
387
+ export function websiteSnapshotProfileComparability(before, after, profile) {
388
+ const left = before.profileProvenance[profile].version;
389
+ const right = after.profileProvenance[profile].version;
390
+ if (left === null || right === null)
391
+ return 'legacy_unknown';
392
+ return left === right ? 'comparable' : 'detector_changed';
393
+ }
369
394
  function compareMap(field, before, after, options = {}) {
370
395
  const changes = [];
371
396
  for (const key of [...new Set([...before.keys(), ...after.keys()])].sort()) {
@@ -394,15 +419,13 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
394
419
  };
395
420
  }
396
421
  const technologyVersionBefore = before.profileProvenance.technology.version;
422
+ const selectedPage = before.webObservationMode === 'selected_url' || after.webObservationMode === 'selected_url';
397
423
  const technologyVersionAfter = after.profileProvenance.technology.version;
398
424
  const postureVersionBefore = before.profileProvenance.securityPosture.version;
399
425
  const postureVersionAfter = after.profileProvenance.securityPosture.version;
400
- const technologyComparability = technologyVersionBefore === null || technologyVersionAfter === null
401
- ? 'legacy_unknown'
402
- : technologyVersionBefore === technologyVersionAfter ? 'comparable' : 'detector_changed';
403
- const postureComparability = postureVersionBefore === null || postureVersionAfter === null
404
- ? 'legacy_unknown'
405
- : postureVersionBefore === postureVersionAfter ? 'comparable' : 'detector_changed';
426
+ const technologyComparability = websiteSnapshotProfileComparability(before, after, 'technology');
427
+ const postureComparability = websiteSnapshotProfileComparability(before, after, 'securityPosture');
428
+ const fingerprintComparability = websiteSnapshotProfileComparability(before, after, 'pageFingerprint');
406
429
  const changes = [
407
430
  ...(technologyComparability === 'comparable' ? compareMap('technology', new Map(before.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), new Map(after.technologies.map((item) => [item.id, `${item.name}|${item.category}|${item.confidence}|${item.roles.join(',')}`])), {
408
431
  beforeComplete: () => websiteSnapshotFieldComplete(before, 'technology'),
@@ -462,20 +485,30 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
462
485
  for (const key of Object.keys(before.identity)) {
463
486
  const left = before.identity[key];
464
487
  const right = after.identity[key];
488
+ if (key !== 'faviconHash' && fingerprintComparability !== 'comparable')
489
+ continue;
465
490
  if (left === right)
466
491
  continue;
467
492
  changes.push({ field: `identity.${key}`, state: left === null || right === null ? 'unavailable' : 'changed', before: left, after: right });
468
493
  }
469
- changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
470
- beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
471
- afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
472
- }), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
473
- beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
474
- afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
475
- }), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
476
- beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
477
- afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
478
- }));
494
+ if (fingerprintComparability !== 'comparable') {
495
+ changes.push({
496
+ field: 'identity.profileVersion', state: 'incomparable',
497
+ before: before.profileProvenance.pageFingerprint.version?.toString() ?? null,
498
+ after: after.profileProvenance.pageFingerprint.version?.toString() ?? null,
499
+ });
500
+ }
501
+ else
502
+ changes.push(...compareMap('identityValues.resourceHosts', new Map(before.identityValues.resourceHosts.map((item) => [item, item])), new Map(after.identityValues.resourceHosts.map((item) => [item, item])), {
503
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.resourceHosts'),
504
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.resourceHosts'),
505
+ }), ...compareMap('identityValues.trackingIdentifiers', new Map(before.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), new Map(after.identityValues.trackingIdentifiers.map((item) => [`${item.type}:${item.value}`, item.value])), {
506
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.trackingIdentifiers'),
507
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.trackingIdentifiers'),
508
+ }), ...compareMap('identityValues.formActionOrigins', new Map(before.identityValues.formActionOrigins.map((item) => [item, item])), new Map(after.identityValues.formActionOrigins.map((item) => [item, item])), {
509
+ beforeComplete: () => websiteSnapshotFieldComplete(before, 'identityValues.formActionOrigins'),
510
+ afterComplete: () => websiteSnapshotFieldComplete(after, 'identityValues.formActionOrigins'),
511
+ }));
479
512
  if (before.certificate || after.certificate) {
480
513
  if (!before.certificate || !after.certificate) {
481
514
  changes.push({
@@ -519,15 +552,22 @@ export function compareWebsiteSnapshots(beforeRaw, afterRaw) {
519
552
  }
520
553
  return {
521
554
  compatible: true,
522
- complete: websiteSnapshotComparisonEvidenceComplete(before)
555
+ complete: !selectedPage && websiteSnapshotComparisonEvidenceComplete(before)
523
556
  && websiteSnapshotComparisonEvidenceComplete(after)
524
557
  && technologyComparability === 'comparable'
525
- && postureComparability === 'comparable',
558
+ && postureComparability === 'comparable'
559
+ && fingerprintComparability === 'comparable',
526
560
  profileComparability: {
527
561
  technology: technologyComparability,
528
562
  securityPosture: postureComparability,
563
+ pageFingerprint: fingerprintComparability,
529
564
  },
530
- changes,
531
- dependencyTransitions,
565
+ changes: selectedPage ? [
566
+ { field: 'web.collectionTarget', state: 'incomparable', before: before.webObservationMode ?? 'homepage', after: after.webObservationMode ?? 'homepage' },
567
+ ...changes.map((change) => change.field.startsWith('certificate.') || change.field.startsWith('source.') || change.field === 'completeness'
568
+ || /^dependency\.(?:CNAME|HTTPS|NS|MX)(?::|$)/u.test(change.field)
569
+ ? change : { ...change, state: 'incomparable' }),
570
+ ] : changes,
571
+ dependencyTransitions: selectedPage ? dependencyTransitions.filter((transition) => transition.recordType !== 'HTTP') : dependencyTransitions,
532
572
  };
533
573
  }
@@ -2,13 +2,13 @@
2
2
  // This protects a downloaded file while it is locked. It does not encrypt the
3
3
  // active IndexedDB workspace or defend an unlocked page from same-origin code.
4
4
  import { WORKSPACE_ARCHIVE_SCHEMA, WORKSPACE_ARCHIVE_VERSION, SUPPORTED_WORKSPACE_ARCHIVE_VERSIONS, isSupportedWorkspaceArchiveVersion, readWorkspaceArchive, } from "./workspace-archive.mjs";
5
- import { parseBoundedJson } from "../../lib/bounded-json.mjs";
5
+ import { boundedJsonLimitsForBytes, parseBoundedJson } from "../../lib/bounded-json.mjs";
6
6
  import { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, } from "../contracts/case-portability.mjs";
7
7
  import { assertWorkspaceInputGraph } from "./hostile-input.mjs";
8
+ import { AES_KEY_BITS, arrayBuffer, assertPassphrase, cryptoProvider, deriveArchiveKey } from "../evidence/passphrase-encryption.mjs";
8
9
  export { ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA, ENCRYPTED_WORKSPACE_ARCHIVE_VERSION, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES, MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS, };
9
10
  const SALT_BYTES = 16;
10
11
  const IV_BYTES = 12;
11
- const AES_KEY_BITS = 256;
12
12
  const AES_GCM_TAG_BITS = 128;
13
13
  const MAX_CIPHERTEXT_BASE64URL_CHARACTERS = Math.ceil((MAX_WORKSPACE_ARCHIVE_BYTES + AES_GCM_TAG_BITS / 8) * 4 / 3);
14
14
  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
@@ -25,25 +25,6 @@ function hasExactKeys(value, expected) {
25
25
  const keys = Object.keys(value);
26
26
  return keys.length === expected.length && keys.every((key) => expected.includes(key));
27
27
  }
28
- function arrayBuffer(bytes) {
29
- return bytes.slice().buffer;
30
- }
31
- function assertPassphrase(passphrase) {
32
- if (typeof passphrase !== 'string') {
33
- throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
34
- }
35
- if (passphrase.length > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
36
- throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
37
- }
38
- if (Array.from(passphrase).length < MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS) {
39
- throw new Error(`Use a backup passphrase with at least ${MIN_WORKSPACE_ARCHIVE_PASSPHRASE_CHARACTERS} characters.`);
40
- }
41
- const bytes = encoder.encode(passphrase);
42
- if (bytes.byteLength > MAX_WORKSPACE_ARCHIVE_PASSPHRASE_BYTES) {
43
- throw new Error('Backup passphrases are limited to 1024 UTF-8 bytes.');
44
- }
45
- return bytes;
46
- }
47
28
  function base64url(bytes) {
48
29
  let encoded = '';
49
30
  for (let index = 0; index < bytes.length; index += 3) {
@@ -112,7 +93,7 @@ function validTimestamp(value) {
112
93
  return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
113
94
  }
114
95
  function validateEnvelope(raw) {
115
- assertWorkspaceInputGraph(raw, 'Encrypted workspace archive');
96
+ assertWorkspaceInputGraph(raw, 'Encrypted workspace archive', { maximumBytes: MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES });
116
97
  const value = record(raw);
117
98
  if (!value || value.schema !== ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA) {
118
99
  throw new Error('This file is not an encrypted WHOISleuth workspace archive.');
@@ -197,26 +178,6 @@ function validateEnvelope(raw) {
197
178
  ciphertext,
198
179
  };
199
180
  }
200
- function cryptoProvider(provider = globalThis.crypto) {
201
- if (!provider
202
- || typeof provider.getRandomValues !== 'function'
203
- || typeof provider.subtle?.importKey !== 'function'
204
- || typeof provider.subtle?.deriveKey !== 'function'
205
- || typeof provider.subtle?.encrypt !== 'function'
206
- || typeof provider.subtle?.decrypt !== 'function') {
207
- throw new Error('Encrypted workspace archives are unavailable in this browser.');
208
- }
209
- return provider;
210
- }
211
- async function deriveArchiveKey(provider, passphraseBytes, salt, usages) {
212
- const material = await provider.subtle.importKey('raw', arrayBuffer(passphraseBytes), 'PBKDF2', false, ['deriveKey']);
213
- return provider.subtle.deriveKey({
214
- name: 'PBKDF2',
215
- hash: 'SHA-256',
216
- salt: arrayBuffer(salt),
217
- iterations: WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS,
218
- }, material, { name: 'AES-GCM', length: AES_KEY_BITS }, false, usages);
219
- }
220
181
  export function isEncryptedWorkspaceArchive(raw) {
221
182
  return record(raw)?.schema === ENCRYPTED_WORKSPACE_ARCHIVE_SCHEMA;
222
183
  }
@@ -227,15 +188,19 @@ export function inspectEncryptedWorkspaceArchive(raw) {
227
188
  }
228
189
  /** Encrypt one already-built workspace archive using browser-native Web Crypto. */
229
190
  export async function encryptWorkspaceArchive(archive, passphrase, provider) {
230
- await readWorkspaceArchive(archive);
231
- const archiveVersion = record(archive)?.version;
232
- if (!isSupportedWorkspaceArchiveVersion(archiveVersion)) {
233
- throw new Error('The workspace archive declares an unsupported content contract.');
234
- }
191
+ assertWorkspaceInputGraph(archive, 'Workspace archive', { maximumBytes: MAX_WORKSPACE_ARCHIVE_BYTES });
235
192
  const plaintext = JSON.stringify(archive);
236
193
  const plaintextBytes = encoder.encode(plaintext);
237
194
  if (plaintextBytes.byteLength > MAX_WORKSPACE_ARCHIVE_BYTES) {
238
- throw new Error('Workspace archives are limited to 10 MiB. Export smaller collections separately before trying again.');
195
+ throw new Error(`Workspace archives are limited to ${MAX_WORKSPACE_ARCHIVE_BYTES / 1024 / 1024} MiB. Export smaller collections separately before trying again.`);
196
+ }
197
+ // Validation and encryption consume the same snapshot, including when the
198
+ // caller changes the original object while checksum verification is pending.
199
+ const snapshot = JSON.parse(plaintext);
200
+ await readWorkspaceArchive(snapshot);
201
+ const archiveVersion = record(snapshot)?.version;
202
+ if (!isSupportedWorkspaceArchiveVersion(archiveVersion)) {
203
+ throw new Error('The workspace archive declares an unsupported content contract.');
239
204
  }
240
205
  const crypto = cryptoProvider(provider);
241
206
  const passphraseBytes = assertPassphrase(passphrase);
@@ -278,30 +243,45 @@ export async function encryptWorkspaceArchive(archive, passphrase, provider) {
278
243
  }
279
244
  /** Decrypt and parse one encrypted archive. The ordinary archive reader remains authoritative. */
280
245
  export async function decryptWorkspaceArchive(raw, passphrase, provider) {
246
+ return (await decryptWorkspaceArchiveWithMetadata(raw, passphrase, provider)).archive;
247
+ }
248
+ /** Authenticate one envelope and return its inner JSON with verified byte metadata. */
249
+ export async function decryptWorkspaceArchiveWithMetadata(raw, passphrase, provider) {
281
250
  const { envelope, salt, iv, ciphertext } = validateEnvelope(raw);
282
251
  const crypto = cryptoProvider(provider);
283
252
  const passphraseBytes = assertPassphrase(passphrase);
284
253
  const { ciphertext: _ciphertext, ...metadata } = envelope;
285
254
  try {
286
- const key = await deriveArchiveKey(crypto, passphraseBytes, salt, ['decrypt']);
287
- const plaintext = await crypto.subtle.decrypt({
288
- name: 'AES-GCM',
289
- iv: arrayBuffer(iv),
290
- additionalData: arrayBuffer(encoder.encode(canonicalMetadata(metadata))),
291
- tagLength: AES_GCM_TAG_BITS,
292
- }, key, arrayBuffer(ciphertext));
255
+ let plaintext;
256
+ try {
257
+ const key = await deriveArchiveKey(crypto, passphraseBytes, salt, ['decrypt']);
258
+ plaintext = await crypto.subtle.decrypt({
259
+ name: 'AES-GCM',
260
+ iv: arrayBuffer(iv),
261
+ additionalData: arrayBuffer(encoder.encode(canonicalMetadata(metadata))),
262
+ tagLength: AES_GCM_TAG_BITS,
263
+ }, key, arrayBuffer(ciphertext));
264
+ }
265
+ catch {
266
+ // Authentication failure cannot distinguish a wrong key from tampering.
267
+ throw new Error('The backup passphrase is incorrect or the encrypted file is corrupted.');
268
+ }
293
269
  if (plaintext.byteLength > MAX_WORKSPACE_ARCHIVE_BYTES) {
294
270
  throw new Error('The decrypted workspace archive exceeds its byte limit.');
295
271
  }
296
- return parseBoundedJson(decoder.decode(plaintext), {
272
+ let json;
273
+ try {
274
+ json = decoder.decode(plaintext);
275
+ }
276
+ catch {
277
+ throw new Error('The backup decrypted, but its contents are not valid UTF-8.');
278
+ }
279
+ const archive = parseBoundedJson(json, {
297
280
  label: 'Decrypted workspace archive',
298
281
  maximumBytes: MAX_WORKSPACE_ARCHIVE_BYTES,
282
+ limits: boundedJsonLimitsForBytes(MAX_WORKSPACE_ARCHIVE_BYTES),
299
283
  });
300
- }
301
- catch (cause) {
302
- if (cause instanceof Error && cause.message === 'The decrypted workspace archive exceeds its byte limit.')
303
- throw cause;
304
- throw new Error('The backup passphrase is incorrect or the encrypted file is corrupted.');
284
+ return { archive, ciphertextBytes: ciphertext.byteLength };
305
285
  }
306
286
  finally {
307
287
  passphraseBytes.fill(0);