Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions docs/3.table-modifiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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$<salt>$<key>`. 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.
Expand All @@ -112,21 +113,33 @@ The `HashModifier` mixin adds these methods to table instances:
```typescript fragment
testHash<This extends {}, F extends keyof This>(this: This, field: F, value: This[F]): boolean
testHashAsync<This extends {}, F extends keyof This>(this: This, field: F, value: This[F]): Promise<boolean>
setHashAsync<This extends object, F extends keyof This>(this: This, field: F, value: This[F]): Promise<void>
isHashOutdated<This extends {}>(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 <name> 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 <name> is not hashed` for a field without `@Hashed`.
- `testHash` gives the same result and blocks the event loop while scrypt runs. It throws `Field <name> 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 <name> 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);
}
}
Expand Down
5 changes: 3 additions & 2 deletions skills/database-decorators-interface/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
89 changes: 78 additions & 11 deletions src/modifiers/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -171,12 +189,11 @@ function storedText(stored: string | undefined) {
function withHashedField<R>(
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`);
}
Expand All @@ -187,7 +204,8 @@ function withHashedField<R>(
entry.id,
entry.modifier,
entry.metaRef,
(modifier) => use(modifier, isString(stored) ? stored : ""),
(modifier) =>
use(modifier, isString(stored) ? stored : "", list.length === 1),
);
}

Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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 extends object, F extends keyof This>(
this: This,
field: F,
value: This[F],
): Promise<void> {
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
Expand Down
24 changes: 24 additions & 0 deletions src/tests/hash_upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
144 changes: 144 additions & 0 deletions src/tests/modifiers/hash_async_set.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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");
});
});
Loading