Skip to content

feat(hash): add setHashAsync to hash a field off the event loop - #110

Merged
Upd4ting merged 3 commits into
mainfrom
feat/ant-1960-async-hash
Nov 10, 2026
Merged

Upd4ting merged 3 commits into
mainfrom
feat/ant-1960-async-hash

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

⚠ sensitive: password hashing.

Why

A password write should not stall the server. @Hashed runs scryptSync inside the field setter, so every assignment blocks the event loop for about 150 ms (ANT-1932 measured 144 to 162 ms). The setter has to stay synchronous, so this adds an async way to set the field. Antony approved the API on 2026-11-10 (ANT-1960).

What changes for a module author

  • New mixin method: await row.setHashAsync(field, value). It runs the scrypt run on the libuv thread pool, then assigns the finished hash through the field's own setter. It uses the same parameters (ln=15, r=8, p=3, 16-byte salt, 32-byte key) and the same stored format $scrypt$ln=15,r=8,p=3$<salt>$<key>. A hash written either way verifies with testHash and testHashAsync, and existing rows still verify.
  • To save the row, pass the instance to insert or update. A row instance goes through the model unconverted, so the hash is kept.
  • The sync setter, testHash, testHashAsync and isHashOutdated are unchanged. Plain-data insert({ password }) and update(id, { password }) still hash on the event loop. The docs say so and point to setHashAsync.
  • setHashAsync behaves like an assignment in these cases: on a field with an algorithm (a digest is fast), on a field with another modifier beside @Hashed, for undefined, and when given the stored hash back.
flowchart LR
  A["row.setHashAsync(field, value)"] --> B{"@Hashed alone,<br/>scrypt, new value?"}
  B -- yes --> C["scrypt on the thread pool"] --> D["PrecomputedHash(stored)"]
  B -- no --> E["value as is"]
  D --> F["row[field] = ... (same setter)"]
  E --> F
  F --> G["HashModifier.lock: keeps a PrecomputedHash,<br/>otherwise hashes as before"]
Loading

PrecomputedHash is private to hash.ts, and so is lockAsync. Neither appears in the published declarations except as private lockAsync;. Data from a request or from the database cannot be an instance of that class, so it cannot skip hashing.

Proof

The event-loop test fails on main and passes after

src/tests/modifiers/hash_async_set.test.ts measures the longest gap between 1 ms timers while a hash runs. A sync assignment calibrates it, and the async path must stay below half of that.

On origin/main with only the new test file added:

  0 passing (475ms)
  6 failing
  1) Modifiers - hash - setHashAsync
     TypeError: account.setHashAsync is not a function

Against a wrong implementation that is async but still hashes on the main thread (built lockAsync patched to return the raw value):

    1) keeps timers running while it hashes, unlike an assignment
  5 passing (2s)
  1 failing
      AssertionError: expected 154.18810899999994 to be below 77.218099

Against the first review round's code (empty value, and a Localized field held floating):

    1) keeps timers running while it hashes "", unlike an assignment
    2) reads back what an assignment reads on a field another modifier holds floating
      AssertionError: expected 146.52485100000013 to be below 75.879005
      AssertionError: expected { Object (stored) } to equal 'correct horse battery staple'

At head:

    ✔ keeps timers running while it hashes "correct horse battery staple", unlike an assignment (316ms)
    ✔ keeps timers running while it hashes "", unlike an assignment (312ms)
    ✔ reads back what an assignment reads on a field another modifier holds floating
    ✔ stores a hash that verifies the same way as an assigned one (940ms)
    ✔ replaces a stored hash and keeps it when given that hash back (472ms)
    ✔ clears the field when given undefined, like an assignment (156ms)
    ✔ stores the digest an assignment stores on a field with an algorithm
    ✔ rejects a field that is not hashed
  8 passing (2s)

src/tests/hash_upgrade.test.ts adds two MongoDB cases: setHashAsync followed by a whole-row update, and setHashAsync followed by insert. Both pass.

Load, before and after

ANT-1932: 10 connections on a cheap route next to one serial signup stream, sync scrypt against async scrypt with the same parameters.

Signup hash Cheap route req/s p97.5
none about 40k
sync (scryptSync, today) 1.2k to 3.1k about 150 ms
async scrypt 35k to 44k 0 ms

Reproduced on this branch's build: 16 cores, autocannon -c 10 -d 10 on an http route answering ok, with one serial loop of new Account() plus a @Hashed write in the same process, 3 rounds each.

Write path req/s p97.5 writes in 10 s
none 70.9k to 71.1k 0 ms 0
row.password = pw 63 to 64 149 to 161 ms 75 to 76
await row.setHashAsync("password", pw) 67.0k to 69.1k 0 ms 79 to 81

The sync row here is lower than ANT-1932's because this loop starts the next write as soon as the last one ends. It holds the main thread for about 76 × 150 ms of each 10 s run, which makes it a worst case. The async row stays within 6 % of no writes.

pnpm run checks

> @antelopejs/interface-database-decorators@0.2.2 checks
> antelopejs-checks

Checks: 11 passed, 0 failed.

Verify layer

An independent verifier ran antelopejs-verify on c925386. The result was ## Verify: PASS+NOTES with One-way-door: format, contract. All 17 journeys in smoke, declarations and hashing passed. The format door is a false positive: an independent node:crypto scrypt reproduced the stored key. The contract door is the added setHashAsync, which ships as a new minor. The notes said lockAsync was public, and the last commit makes it private. They also noted that no harness journey calls setHashAsync yet. The test suite and a probe of the build covered it instead.

Review

An independent reviewer (claude-fable-5-1) ran two rounds. Round 1 found three verified defects, all fixed in c925386:

  • An empty value fell back to sync scrypt.
  • A Localized field held floating returned the internal wrapper.
  • An Encrypted modifier under @Hashed re-hashed on the event loop.

Round 2 had no verified findings.

Release

Needs a minor release, 0.3.0, because of the new public method. The release commit sets the version.

Follow-ups

  • DMS moves its six password writes to setHashAsync once this is released (ANT-1960 Done means).
  • Pre-existing on main and not changed here: stacking another modifier under @Hashed stores a hash of the wrapped value in both write paths, so testHash fails for that field. It deserves its own issue.
  • The verify layer's hashing feature could get a setHashAsync journey.

@Upd4ting
Upd4ting marked this pull request as ready for review November 10, 2026 20:26
@Upd4ting
Upd4ting merged commit a7aa790 into main Nov 10, 2026
8 checks passed
@Upd4ting
Upd4ting deleted the feat/ant-1960-async-hash branch November 10, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant