diff --git a/README.md b/README.md index eced919..832b0f9 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Part of [AntelopeJS](https://github.com/AntelopeJS/antelopejs). Used by [Antelop - `Table`, `@RegisterTable`, `@Field`, `@Index` and `@Fixture` declare tables, indexes and seed rows; `RegisterSchema` provisions them. - `BasicDataModel` and `GetModel` give each table typed CRUD methods with optional validation. - `@Encrypted`, `@Hashed`, `@Localized`, `@CreationTime` and `@UpdateTime` transform field values on write and read. +- `await row.setHashAsync(field, value)` and `await row.testHashAsync(field, value)` hash a password off the event loop. Use them on a server: assigning a `@Hashed` field blocks the event loop for about 150 ms. See [Table modifiers](./docs/3.table-modifiers.md#mixin-methods). - `@Model` injects a model into an [`@antelopejs/interface-api`](https://github.com/AntelopeJS/interface-api) controller. ## Implementations diff --git a/docs/3.table-modifiers.md b/docs/3.table-modifiers.md index fececcb..efe0f3f 100644 --- a/docs/3.table-modifiers.md +++ b/docs/3.table-modifiers.md @@ -91,7 +91,8 @@ class User extends Table.with(HashModifier) { - The field hashes the value encoded as JSON, so `"secret"` is hashed as the text `"\"secret\""`. - Each write draws a random 16-byte salt and stores `$scrypt$ln=15,r=8,p=3$$`. The salt and the 32-byte key are base64 without padding. `ln` is the base-2 logarithm of the scrypt cost N, so N is 32768. The string holds the parameters, the salt, and the key, so the cost can change later. -- A write is synchronous, because a property setter cannot wait, and so are the conversions behind a model's `insert` and `update`. Each assignment blocks the event loop for one scrypt run, which uses 32 MiB (128 × N × r bytes). +- An assignment is synchronous, because a property setter cannot wait, and so are the conversions behind a model's `insert` and `update` of plain data. Each assignment blocks the event loop for one scrypt run, about 150 ms, which uses 32 MiB (128 × N × r bytes). +- `setHashAsync` runs that scrypt run on the thread pool, then assigns the result. It stores the same format as an assignment, so rows written either way verify the same way. - Assigning the stored hash back to the field, or calling `lock` on a loaded row, keeps the hash as it is. Assigning `undefined` stores nothing. - `testHash` and `testHashAsync` read `ln`, `r`, `p`, the salt, and the key from the stored string. They derive a key of the stored key's length from the candidate and compare the two with `crypto.timingSafeEqual`, in constant time. - A stored scrypt string verifies only within these limits: `ln`, `r`, and `p` from 1 to 99, at most 128 MiB of memory, `p` at most 16, `ln` below 16 × `r`, and a salt and a key of 16 to 64 bytes each. A string outside them refuses every candidate. @@ -112,21 +113,33 @@ The `HashModifier` mixin adds these methods to table instances: ```typescript fragment testHash(this: This, field: F, value: This[F]): boolean testHashAsync(this: This, field: F, value: This[F]): Promise +setHashAsync(this: This, field: F, value: This[F]): Promise isHashOutdated(this: This, field: keyof This): boolean ``` +- `setHashAsync` hashes `value` on the thread pool and resolves once the field holds the hash, as `row[field] = value` would leave it. Use it on a server for every write of a scrypt field, such as a signup or a password change, then save the row instance with the model. It rejects with `Field is not hashed` for a field without `@Hashed`. On a field with an `algorithm`, it assigns at once, because a digest is fast. On a field with another modifier beside `@Hashed`, it also assigns at once, on the event loop. Calls on one row that overlap settle in the order their scrypt runs end, so await each one before the next. + +```typescript fragment +const user = new User(); +user.email = email; +await user.setHashAsync("password", password); +await userModel.insert(user); +``` + +`userModel.insert({ email, password })` and `userModel.update(id, { password })` still hash on the event loop, because they convert plain data through the setter. + - `testHashAsync` resolves whether `value` matches the stored hash. It runs scrypt on the thread pool, so use it on a server. It rejects with `Field is not hashed` for a field without `@Hashed`. - `testHash` gives the same result and blocks the event loop while scrypt runs. It throws `Field is not hashed` for a field without `@Hashed`. - `isHashOutdated` returns whether the stored hash differs from what the field writes today, which is any stored string that does not start with `$scrypt$ln=15,r=8,p=3$`. It returns `false` when nothing is stored, and always on a field with an `algorithm`. It throws `Field is not hashed` for a field without `@Hashed`. #### Upgrade stored hashes -A row whose hash has another format still verifies. On a field without an `algorithm`, a string that is not a scrypt hash is tested as a salted SHA-256 digest, made with the salt in `_internal.meta.HashModifier.salt`, and `isHashOutdated` returns `true` for it. A scrypt hash of another cost verifies with its own parameters and is flagged too. To rewrite a flagged hash, assign the value after a successful test, because only then is the plaintext known: +A row whose hash has another format still verifies. On a field without an `algorithm`, a string that is not a scrypt hash is tested as a salted SHA-256 digest, made with the salt in `_internal.meta.HashModifier.salt`, and `isHashOutdated` returns `true` for it. A scrypt hash of another cost verifies with its own parameters and is flagged too. To rewrite a flagged hash, set the value after a successful test, because only then is the plaintext known: ```typescript fragment if (await user.testHashAsync("password", password)) { if (user.isHashOutdated("password")) { - user.password = password; + await user.setHashAsync("password", password); await userModel.update(user); } } diff --git a/skills/database-decorators-interface/SKILL.md b/skills/database-decorators-interface/SKILL.md index 0c8a470..6eaaeca 100644 --- a/skills/database-decorators-interface/SKILL.md +++ b/skills/database-decorators-interface/SKILL.md @@ -110,7 +110,7 @@ class Chef extends Table.with( EncryptionModifier, LocalizationModifier, ) { - @Hashed() declare passphrase: string; // one-way scrypt; test via await instance.testHashAsync("passphrase", value) + @Hashed() declare passphrase: string; // one-way scrypt; set via await instance.setHashAsync("passphrase", value), test via await instance.testHashAsync("passphrase", value) @Encrypted({ secretKey }) declare supplierCode: string; // transparent encrypt/decrypt (autolock + autounlock) @Localized() declare motto: string; // read AND write via instance.localize("en", ["motto"]) @CreationTime() declare hiredAt: Date; // set on insert, stripped from updates @@ -125,7 +125,8 @@ class Chef extends Table.with( - `Hashed`, `Encrypted`, and `Localized` require the matching modifier class in `Table.with(...)`; `CreationTime`/`UpdateTime` are event-only and need no mixin. - Modifier ordering matters: `attachModifier` throws if you stack a modifier after a one-way modifier (e.g. anything after `@Hashed` on the same field). - Hashed fields cannot be read back or queried directly — only equality-tested with `testHashAsync`, or `testHash`, which blocks the event loop for one scrypt run. -- A row can hold an older hash that still verifies. After a successful test, when `instance.isHashOutdated(field)` is true, assign the tested value and save the row. +- On a server, write a scrypt field with `await instance.setHashAsync(field, value)`, then insert or update the instance. Assigning the field, or passing it in plain data to `insert` or `update`, blocks the event loop for one scrypt run (about 150 ms). +- A row can hold an older hash that still verifies. After a successful test, when `instance.isHashOutdated(field)` is true, `await instance.setHashAsync(field, value)` with the tested value and save the row. - `@Hashed({ algorithm })` stores a fast digest instead of scrypt: use it only for non-secret fingerprints, never for credentials. - Localized fields must be written through a localized instance (`instance.localize(locale).field = ...`); values assigned before `localize()` are held in floating state and silently dropped on insert/update. diff --git a/src/modifiers/hash.ts b/src/modifiers/hash.ts index 7cba6b8..734202c 100644 --- a/src/modifiers/hash.ts +++ b/src/modifiers/hash.ts @@ -93,6 +93,24 @@ function scryptAsync(...[password, salt, keylen, options]: ScryptArgs) { }); } +/** A new salt and the scrypt arguments that derive the current hash of `candidate` with it. */ +function currentScrypt(candidate: string) { + const salt = randomBytes(SALT_BYTES); + return { + salt, + run: scryptArgs(candidate, salt, KEY_BYTES, SCRYPT_COST), + }; +} + +function storedScrypt(salt: Buffer, key: Buffer) { + return `${CURRENT_PREFIX}${unpaddedBase64(salt)}$${unpaddedBase64(key)}`; +} + +/** A stored hash derived ahead of the setter, which `lock` keeps as it is. */ +class PrecomputedHash { + public constructor(public readonly stored: string) {} +} + function parseScrypt(stored: string) { const match = STORED_SCRYPT.exec(stored); if (!match) return undefined; @@ -171,12 +189,11 @@ function storedText(stored: string | undefined) { function withHashedField( row: { constructor: any }, field: PropertyKey, - use: (modifier: HashModifier, stored: string) => R, + use: (modifier: HashModifier, stored: string, alone: boolean) => R, ) { const metaTable = getMetadata(row.constructor, ModifiersStaticMetadata); - const entry = fieldsByKey(metaTable)[field]?.find( - ({ modifier }) => modifier instanceof HashModifier, - ); + const list = fieldsByKey(metaTable)[field]; + const entry = list?.find(({ modifier }) => modifier instanceof HashModifier); if (!entry || !(entry.modifier instanceof HashModifier)) { throw new Error(`Field ${String(field)} is not hashed`); } @@ -187,7 +204,8 @@ function withHashedField( entry.id, entry.modifier, entry.metaRef, - (modifier) => use(modifier, isString(stored) ? stored : ""), + (modifier) => + use(modifier, isString(stored) ? stored : "", list.length === 1), ); } @@ -210,13 +228,17 @@ export class HashModifier extends OneWayModifier< /** * Hashes a new value. Assigning the stored hash back keeps it as it is. * Blocks the event loop for one scrypt run: a setter and the conversions - * behind `insert` and `update` are synchronous. + * behind `insert` and `update` are synchronous. `setHashAsync` runs it on + * the thread pool instead. */ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- overrides OneWayModifier.lock; a modified field's value has its table class's type, which the shared modifier chain cannot name public override lock(locked_value: string | undefined, value: unknown) { if (value === undefined) { return value; } + if (value instanceof PrecomputedHash) { + return value.stored; + } if (value === locked_value) { return locked_value; } @@ -228,11 +250,27 @@ export class HashModifier extends OneWayModifier< } return digestOf(algorithm, this.meta.salt, candidate); } - const salt = randomBytes(SALT_BYTES); - const key = scryptSync( - ...scryptArgs(candidate, salt, KEY_BYTES, SCRYPT_COST), - ); - return `${CURRENT_PREFIX}${unpaddedBase64(salt)}$${unpaddedBase64(key)}`; + const { salt, run } = currentScrypt(candidate); + return storedScrypt(salt, scryptSync(...run)); + } + + /** + * What to assign to the field so that its setter stores the hash of `value` + * without running scrypt: the scrypt run happens here, on the thread pool. + * A value the setter handles without scrypt comes back as it is: nothing, + * the stored hash itself (`locked_value`, empty when nothing is stored), or + * any value of a field with an `algorithm`. + */ + private async lockAsync(locked_value: string, value: HashInput) { + if ( + value === undefined || + (locked_value !== "" && value === locked_value) || + this.options.algorithm + ) { + return value; + } + const { salt, run } = currentScrypt(JSON.stringify(value)); + return new PrecomputedHash(storedScrypt(salt, await scryptAsync(...run))); } public override test(locked_value: string | undefined, value: HashInput) { @@ -308,6 +346,35 @@ export class HashModifier extends OneWayModifier< ); } + /** + * Hash a value on the thread pool, then assign it to the field. The row + * stores what `this[field] = value` stores, in the same format, without + * blocking the event loop for the scrypt run. On a server, prefer it to + * assigning a hashed field. A field with another modifier beside + * {@link Hashed} is assigned at once, on the event loop. Calls on one row + * that overlap settle in the order their scrypt runs end: await each one. + * + * @param field - Name of the field to set. + * @param value - Value to hash and store. + * + * @returns Once the field holds the hash of `value`. + * + * @throws Error when `field` has no {@link Hashed} decorator. + */ + async setHashAsync( + this: This, + field: F, + value: This[F], + ): Promise { + const assigned = await withHashedField( + this, + field, + (modifier, stored, alone) => + alone ? modifier.lockAsync(stored, value) : value, + ); + Reflect.set(this, field, assigned); + } + /** * Tell whether a stored hash needs writing again: it uses an older format * or cost. After a successful test, assign the tested value to the field diff --git a/src/tests/hash_upgrade.test.ts b/src/tests/hash_upgrade.test.ts index 74c7db6..3ac376f 100644 --- a/src/tests/hash_upgrade.test.ts +++ b/src/tests/hash_upgrade.test.ts @@ -85,6 +85,30 @@ scryptSuite("Integration - upgrade of stored hashes", () => { expect(after.isHashOutdated("password")).to.equal(false); }); + it("re-hashes with setHashAsync and saves it through a whole-row update", async () => { + const id = await storedRowFrom0_1_11(); + const before = await reload(id); + + await before.setHashAsync("password", PASSWORD); + await model.update(before); + + const after = await reload(id); + expect(after.password).to.match(/^\$scrypt\$ln=15,r=8,p=3\$/); + await expectVerifies(after); + expect(after.isHashOutdated("password")).to.equal(false); + }); + + it("inserts a row whose hash setHashAsync wrote", async () => { + const account = new Account(); + account.name = "Async"; + await account.setHashAsync("password", PASSWORD); + const [id] = await model.insert(account); + + const after = await reload(id); + expect(after.password).to.equal(account.password); + await expectVerifies(after); + }); + it("re-hashes through a partial update of the hashed field", async () => { const id = await storedRowFrom0_1_11(); diff --git a/src/tests/modifiers/hash_async_set.test.ts b/src/tests/modifiers/hash_async_set.test.ts new file mode 100644 index 0000000..4c8c77e --- /dev/null +++ b/src/tests/modifiers/hash_async_set.test.ts @@ -0,0 +1,144 @@ +import { expect } from "chai"; +import { Field, Table } from "../../table"; +import { fromDatabase, toDatabase } from "../../modifiers/common"; +import { Hashed, HashModifier } from "../../modifiers/hash"; +import { Localized, LocalizationModifier } from "../../modifiers/localization"; +import { PASSWORD, scryptSuite } from "../helpers/hash-rows"; + +const WRONG_PASSWORD = "not the password"; +const CURRENT_FORMAT = + /^\$scrypt\$ln=15,r=8,p=3\$[A-Za-z0-9+/]{22}\$[A-Za-z0-9+/]{43}$/; + +class Account extends Table.with(HashModifier) { + declare _id: string; + + @Hashed() + @Field("string") + declare password?: string; + + @Hashed({ algorithm: "sha256" }) + @Field("string") + declare fingerprint: string; + + @Field("string") + declare email: string; +} + +function storedPassword(account: Account) { + return toDatabase(account)._internal?.data.password; +} + +function reloaded(account: Account) { + return fromDatabase(structuredClone(toDatabase(account)), Account); +} + +async function expectTests(account: Account, password: string) { + const pending = account.testHashAsync("password", password); + const matches = account.testHash("password", password); + expect(await pending).to.equal(matches); + return matches; +} + +/** The longest gap between two timer ticks while `work` runs, in ms. */ +async function longestStallMs(work: () => Promise) { + let last = performance.now(); + let longest = 0; + let ticking = true; + const tick = () => { + const now = performance.now(); + longest = Math.max(longest, now - last); + last = now; + if (ticking) setTimeout(tick, 1); + }; + setTimeout(tick, 1); + await work(); + await new Promise((resolve) => setTimeout(resolve, 5)); + ticking = false; + return longest; +} + +scryptSuite("Modifiers - hash - setHashAsync", () => { + for (const password of [PASSWORD, ""]) { + it(`keeps timers running while it hashes ${JSON.stringify(password)}, unlike an assignment`, async () => { + const blocked = await longestStallMs(async () => { + new Account().password = password; + }); + const account = new Account(); + const free = await longestStallMs(() => + account.setHashAsync("password", password), + ); + + expect(storedPassword(account)).to.match(CURRENT_FORMAT); + expect(free).to.be.below(blocked / 2); + }); + } + + it("reads back what an assignment reads on a field another modifier holds floating", async () => { + class Content extends Table.with(HashModifier, LocalizationModifier) { + declare title: string; + } + Localized()(Content.prototype, "title"); + Hashed()(Content.prototype, "title"); + const assigned = new Content(); + assigned.title = PASSWORD; + const awaited = new Content(); + await awaited.setHashAsync("title", PASSWORD); + + expect(awaited.title).to.equal(assigned.title); + expect(JSON.stringify(awaited)).to.equal(JSON.stringify(assigned)); + }); + + it("stores a hash that verifies the same way as an assigned one", async () => { + const assigned = new Account(); + assigned.password = PASSWORD; + const awaited = new Account(); + await awaited.setHashAsync("password", PASSWORD); + + for (const account of [assigned, awaited].map(reloaded)) { + expect(storedPassword(account)).to.match(CURRENT_FORMAT); + expect(account.isHashOutdated("password")).to.equal(false); + expect(await expectTests(account, PASSWORD)).to.equal(true); + expect(await expectTests(account, WRONG_PASSWORD)).to.equal(false); + } + expect(storedPassword(awaited)).to.not.equal(storedPassword(assigned)); + }); + + it("replaces a stored hash and keeps it when given that hash back", async () => { + const account = new Account(); + account.password = WRONG_PASSWORD; + await account.setHashAsync("password", PASSWORD); + const stored = storedPassword(account); + expect(await expectTests(account, PASSWORD)).to.equal(true); + + await account.setHashAsync("password", account.password); + expect(storedPassword(account)).to.equal(stored); + }); + + it("clears the field when given undefined, like an assignment", async () => { + const account = new Account(); + await account.setHashAsync("password", PASSWORD); + await account.setHashAsync("password", undefined); + expect(storedPassword(account)).to.equal(undefined); + }); + + it("stores the digest an assignment stores on a field with an algorithm", async () => { + const account = new Account(); + account.fingerprint = "first"; + await account.setHashAsync("fingerprint", "second"); + const viaAsync = toDatabase(account)._internal?.data.fingerprint; + account.fingerprint = "second"; + expect(viaAsync).to.equal(toDatabase(account)._internal?.data.fingerprint); + }); + + it("rejects a field that is not hashed", async () => { + const error = await new Account() + .setHashAsync("email", "a@example.test") + .then( + () => undefined, + (reason: unknown) => reason, + ); + expect(error) + .to.be.an("error") + .with.property("message", "Field email is not hashed"); + }); +});