Mixed Hash Type Detection & Automatic Campaign Splitting (#202)#
Status: Shipped — PR #297
Related: builds on the single-hashcat-mode-per-campaign invariant from #100
Problem#
A hash list can contain a mix of hash types — e.g. NTLM hashes alongside sha512crypt hashes in
the same uploaded file. HashHive enforces a hard invariant (#100) that a campaign runs exactly one
hashcat mode. Before this feature, a mixed list either failed outright when a campaign was created
against it, or silently produced wrong results if the mismatch wasn't caught. There was no way to
crack a mixed list without the operator manually pre-splitting the file by hand.
Solution shape#
The feature has two halves that shipped together: detection at ingestion time, and split
at campaign-creation time. The design deliberately keeps them separate — detection is cheap and
always runs; splitting is an async, potentially expensive operation that only runs when an operator
actually tries to crack a mixed list.
Detection (ingestion)#
When a hash list is ingested (packages/backend/src/queue/workers/hash-list-parser.ts), every
entry is run through guessTopHashType, a short-circuit fast-path classifier in
services/hash-analysis.ts. The parser accumulates a per-list type_analysis: a histogram of
detected hashcat modes, an unidentified count, and a verdict of homogeneous, mixed, or
needs-review. This accumulation is deduplicated in-memory and scan-capped so it stays cheap on
large lists, then persisted to the nullable hash_lists.type_analysis jsonb column. The dashboard
hash-list detail view surfaces a warning banner when a list comes back mixed or needs review.
Detection has no side effects beyond writing that column — it does not split anything, and a mixed
list can sit untouched indefinitely.
Split (campaign creation)#
Splitting only happens when an operator creates a campaign against a mixed list. Two new
self-referencing nullable foreign keys carry the parent/child relationship: hash_lists.parent_hash_list_id
and campaigns.parent_campaign_id. hash_items.detected_hashcat_mode records the per-item
classification.
Creating a campaign against a mixed list enqueues an async BullMQ job, HASH_LIST_SPLIT
(queue/workers/hash-list-split.ts, core logic in runSplitAnalysis). It classifies each hash
entry into one of three buckets:
- Confident — exactly one candidate hashcat mode. Auto-configured.
- Ambiguous — two or more candidate modes. Paused for operator review.
- Unidentified — no candidate mode. Paused for operator review.
The campaign creation wizard polls GET /api/v1/dashboard/campaigns/split/status/{id} while the
job runs. Confident type-groups configure themselves automatically; ambiguous and unidentified
groups pause the wizard for a per-type-group review step. When the operator confirms
(POST /campaigns/split/confirm), the system creates one parent campaign plus one single-mode
sub-campaign per resolved sub-list — each sub-campaign still honors the #100 invariant
individually. Results and progress for the whole family aggregate under the parent via
resolveHashListScope(id, projectId) (services/hash-items/list-scope.ts), which expands a parent
hash list to its children in a single project-scoped query.
Key design decisions#
- Work with the #100 invariant, don't remove it. The split produces N single-mode
sub-campaigns under one parent rather than trying to make a campaign multi-mode. Every
hashcat-facing unit of work is still exactly one mode. - Split lazily, at campaign creation, not at ingestion. Detection is always-on and cheap;
splitting is deferred until an operator actually needs to crack the list, so lists that are
uploaded but never cracked never pay the split cost. - JtR-format entries route through needs-review in v1. JtR hash detection and conversion is
deferred to a later phase — it's blocked on the JtR cracking engine, which hasn't shipped yet
(HashHive is hashcat-only today; JtR is a planned second engine).
Security#
resolveHashListScopere-filters expanded child hash lists byproject_idbefore returning
them — this closes an IDOR path where a parent list's children could otherwise leak across
project boundaries.- A database trigger,
hash_lists_parent_project_check, pins a sub-list'sproject_idto match
its parent's at write time, so the constraint holds even for direct DB writes, not just
application-layer queries.
Key files#
packages/backend/src/services/hash-analysis.ts—guessTopHashTypefast-path classifierpackages/backend/src/queue/workers/hash-list-parser.ts— ingestion-timetype_analysisaccumulationpackages/backend/src/queue/workers/hash-list-split.ts—HASH_LIST_SPLITjob,runSplitAnalysispackages/backend/src/services/hash-items/list-scope.ts—resolveHashListScopepackages/backend/src/services/hash-items/{move-items,split-analysis,split-progress,type-analysis}.tspackages/backend/src/services/campaign-split.ts,campaign-split-status.tspackages/shared/src/schemas/{hash-lists,campaign-split,resources}.ts— wire shapes- migrations
0039and0040— schema fortype_analysis,parent_hash_list_id,parent_campaign_id,detected_hashcat_mode, and the parent-project-check trigger