@magield/node 0.0.0-stage → 0.7.1
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.
- package/LICENSE +27 -0
- package/dist/breaker.d.ts +41 -0
- package/dist/breaker.js +91 -0
- package/dist/client.d.ts +100 -0
- package/dist/client.js +407 -0
- package/dist/decisions.d.ts +17 -0
- package/dist/decisions.js +60 -0
- package/dist/express.d.ts +69 -0
- package/dist/express.js +59 -0
- package/dist/fastify.d.ts +39 -0
- package/dist/fastify.js +29 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/nest.d.ts +45 -0
- package/dist/nest.js +39 -0
- package/dist/password.d.ts +25 -0
- package/dist/password.js +30 -0
- package/dist/types.d.ts +273 -0
- package/dist/types.js +16 -0
- package/package.json +39 -3
- package/README.md +0 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Decision, Mode, Outcome } from '@magield/proto';
|
|
2
|
+
/**
|
|
3
|
+
* The only place two spellings of the same thing exist.
|
|
4
|
+
*
|
|
5
|
+
* The wire uses fully prefixed enum names — `DECISION_ALLOW_FLAGGED`,
|
|
6
|
+
* `OUTCOME_LOGIN_SUCCESS` — and `docs/api.md` §5 publishes the short lower-case
|
|
7
|
+
* form to integrators. Both are right for their audience and neither should
|
|
8
|
+
* leak into the other's, so the translation happens once, here.
|
|
9
|
+
*
|
|
10
|
+
* The generated `decisionToJSON` and `outcomeToJSON` are deliberately not used.
|
|
11
|
+
* They **throw** on a value they do not recognise, which is the wrong behaviour
|
|
12
|
+
* for a client: a gateway one minor version ahead that adds a decision would
|
|
13
|
+
* crash an older SDK rather than degrade. Additive changes within `v1` are
|
|
14
|
+
* promised (`docs/api.md` §7), so an unknown decision is a thing that will
|
|
15
|
+
* happen, and the safe reading of one is the most conservative neighbour rather
|
|
16
|
+
* than an exception thrown into somebody's sign-in.
|
|
17
|
+
*/
|
|
18
|
+
const DECISIONS = new Map([
|
|
19
|
+
[Decision.DECISION_ALLOW, 'allow'],
|
|
20
|
+
[Decision.DECISION_ALLOW_FLAGGED, 'allow_flagged'],
|
|
21
|
+
[Decision.DECISION_CHALLENGE, 'challenge'],
|
|
22
|
+
[Decision.DECISION_STEP_UP, 'step_up'],
|
|
23
|
+
[Decision.DECISION_THROTTLE, 'throttle'],
|
|
24
|
+
[Decision.DECISION_DENY, 'deny'],
|
|
25
|
+
[Decision.DECISION_BAN, 'ban'],
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Reads a decision off the wire.
|
|
29
|
+
*
|
|
30
|
+
* `DECISION_UNSPECIFIED` and anything this build has never heard of both become
|
|
31
|
+
* `allow_flagged`: let it through, and record that something was there. Mapping
|
|
32
|
+
* an unknown decision to `allow` would discard a signal the gateway meant to
|
|
33
|
+
* send; mapping it to `deny` would block real people because a server was
|
|
34
|
+
* upgraded first. `allow_flagged` is the reading that is wrong in the smallest
|
|
35
|
+
* way in either direction.
|
|
36
|
+
*/
|
|
37
|
+
export function decisionName(decision) {
|
|
38
|
+
return DECISIONS.get(decision) ?? 'allow_flagged';
|
|
39
|
+
}
|
|
40
|
+
const OUTCOMES = new Map([
|
|
41
|
+
['login_success', Outcome.OUTCOME_LOGIN_SUCCESS],
|
|
42
|
+
['login_failure', Outcome.OUTCOME_LOGIN_FAILURE],
|
|
43
|
+
['mfa_success', Outcome.OUTCOME_MFA_SUCCESS],
|
|
44
|
+
['mfa_failure', Outcome.OUTCOME_MFA_FAILURE],
|
|
45
|
+
['confirmed_good', Outcome.OUTCOME_CONFIRMED_GOOD],
|
|
46
|
+
['confirmed_fraud', Outcome.OUTCOME_CONFIRMED_FRAUD],
|
|
47
|
+
['chargeback', Outcome.OUTCOME_CHARGEBACK],
|
|
48
|
+
['manual_review_good', Outcome.OUTCOME_MANUAL_REVIEW_GOOD],
|
|
49
|
+
['manual_review_bad', Outcome.OUTCOME_MANUAL_REVIEW_BAD],
|
|
50
|
+
['user_complaint', Outcome.OUTCOME_USER_COMPLAINT],
|
|
51
|
+
['support_unblock', Outcome.OUTCOME_SUPPORT_UNBLOCK],
|
|
52
|
+
]);
|
|
53
|
+
export function outcomeValue(outcome) {
|
|
54
|
+
return OUTCOMES.get(outcome) ?? Outcome.OUTCOME_UNSPECIFIED;
|
|
55
|
+
}
|
|
56
|
+
/** For display and logging only. Never compared against. */
|
|
57
|
+
export function modeName(mode) {
|
|
58
|
+
return (Mode[mode] ?? 'MODE_UNSPECIFIED').replace(/^MODE_/, '').toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=decisions.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ShieldClient } from './client.js';
|
|
2
|
+
import type { EvaluateInput } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Express middleware.
|
|
5
|
+
*
|
|
6
|
+
* Opt-in and explicit, per `PLAN.md` §4.2, and behind its own subpath so
|
|
7
|
+
* importing the client pulls none of this in.
|
|
8
|
+
*
|
|
9
|
+
* **It enforces nothing.** It scores the request, attaches the verdict, and
|
|
10
|
+
* calls `next()`. That is not laziness — it is the product's central claim:
|
|
11
|
+
* the engine advises and the integrator enforces, because what a denial should
|
|
12
|
+
* do to *your* sign-in page is a decision only you can make. A middleware that
|
|
13
|
+
* returned 403 on its own would be taking it.
|
|
14
|
+
*
|
|
15
|
+
* ## Why the types are structural
|
|
16
|
+
*
|
|
17
|
+
* Express is not a dependency of this package, and nor are its types. The three
|
|
18
|
+
* properties used here are the three declared below, so this works against
|
|
19
|
+
* Express 4 and 5 without pinning either, and a partner on a fork or a
|
|
20
|
+
* compatible router is not excluded by a version range we had no reason to
|
|
21
|
+
* pick.
|
|
22
|
+
*/
|
|
23
|
+
/** The three things this middleware touches. */
|
|
24
|
+
export interface ExpressRequestLike {
|
|
25
|
+
readonly body?: unknown;
|
|
26
|
+
readonly ip?: string | undefined;
|
|
27
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
export type ExpressNext = (err?: unknown) => void;
|
|
31
|
+
export interface ShieldMiddlewareOptions {
|
|
32
|
+
readonly action: string;
|
|
33
|
+
/** Pulls the sealed token off the request. Defaults to `body.shieldToken`. */
|
|
34
|
+
readonly token?: (req: ExpressRequestLike) => string | undefined;
|
|
35
|
+
/** Business identifiers for this request. */
|
|
36
|
+
readonly keys?: (req: ExpressRequestLike) => EvaluateInput['keys'];
|
|
37
|
+
/**
|
|
38
|
+
* Where to hang the verdict. `req.shield` by default.
|
|
39
|
+
*
|
|
40
|
+
* Named rather than fixed because a property this middleware invents on
|
|
41
|
+
* somebody else's request object can collide, and silently.
|
|
42
|
+
*/
|
|
43
|
+
readonly property?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Scores the request and attaches the verdict.
|
|
47
|
+
*
|
|
48
|
+
* ```ts
|
|
49
|
+
* app.post('/login', shieldExpress(shield, {
|
|
50
|
+
* action: 'login',
|
|
51
|
+
* keys: (req) => ({ username: (req.body as { username?: string }).username }),
|
|
52
|
+
* }), (req, res) => {
|
|
53
|
+
* const verdict = (req as { shield: Verdict }).shield
|
|
54
|
+
* if (verdict.decision === 'deny') return res.status(401).json({ error: 'try again' })
|
|
55
|
+
* if (verdict.decision === 'challenge') return res.status(401).json({ challenge: verdict.challenge })
|
|
56
|
+
* // …sign them in, then: await verdict.reportOutcome('login_success')
|
|
57
|
+
* })
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export declare function shieldExpress(shield: ShieldClient, options: ShieldMiddlewareOptions): (req: ExpressRequestLike, res: unknown, next: ExpressNext) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Flattens the header bag.
|
|
63
|
+
*
|
|
64
|
+
* Node gives repeated headers as an array; the wire wants one string each. The
|
|
65
|
+
* order is preserved because header *order* is itself a signal — a browser
|
|
66
|
+
* sends its headers in a stable order and a script usually does not.
|
|
67
|
+
*/
|
|
68
|
+
export declare function stringHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
69
|
+
//# sourceMappingURL=express.d.ts.map
|
package/dist/express.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scores the request and attaches the verdict.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* app.post('/login', shieldExpress(shield, {
|
|
6
|
+
* action: 'login',
|
|
7
|
+
* keys: (req) => ({ username: (req.body as { username?: string }).username }),
|
|
8
|
+
* }), (req, res) => {
|
|
9
|
+
* const verdict = (req as { shield: Verdict }).shield
|
|
10
|
+
* if (verdict.decision === 'deny') return res.status(401).json({ error: 'try again' })
|
|
11
|
+
* if (verdict.decision === 'challenge') return res.status(401).json({ challenge: verdict.challenge })
|
|
12
|
+
* // …sign them in, then: await verdict.reportOutcome('login_success')
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function shieldExpress(shield, options) {
|
|
17
|
+
const property = options.property ?? 'shield';
|
|
18
|
+
return (req, _res, next) => {
|
|
19
|
+
// `void` rather than `async`: an express middleware that returns a promise
|
|
20
|
+
// has its rejection swallowed by the router, and `evaluate` does not reject
|
|
21
|
+
// anyway — a failure arrives as a degraded verdict.
|
|
22
|
+
void shield
|
|
23
|
+
.evaluate({
|
|
24
|
+
action: options.action,
|
|
25
|
+
...(defaultToken(req, options) === undefined ? {} : { token: defaultToken(req, options) }),
|
|
26
|
+
...(options.keys === undefined ? {} : { keys: options.keys(req) }),
|
|
27
|
+
...(req.ip === undefined ? {} : { ip: req.ip }),
|
|
28
|
+
network: { headers: stringHeaders(req.headers) },
|
|
29
|
+
})
|
|
30
|
+
.then((verdict) => {
|
|
31
|
+
;
|
|
32
|
+
req[property] = verdict;
|
|
33
|
+
next();
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function defaultToken(req, options) {
|
|
38
|
+
if (options.token !== undefined)
|
|
39
|
+
return options.token(req);
|
|
40
|
+
const body = req.body;
|
|
41
|
+
return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Flattens the header bag.
|
|
45
|
+
*
|
|
46
|
+
* Node gives repeated headers as an array; the wire wants one string each. The
|
|
47
|
+
* order is preserved because header *order* is itself a signal — a browser
|
|
48
|
+
* sends its headers in a stable order and a script usually does not.
|
|
49
|
+
*/
|
|
50
|
+
export function stringHeaders(headers) {
|
|
51
|
+
const flat = {};
|
|
52
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
53
|
+
if (value === undefined)
|
|
54
|
+
continue;
|
|
55
|
+
flat[name] = Array.isArray(value) ? value.join(', ') : value;
|
|
56
|
+
}
|
|
57
|
+
return flat;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=express.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ShieldClient } from './client.js';
|
|
2
|
+
import type { EvaluateInput } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A Fastify preHandler.
|
|
5
|
+
*
|
|
6
|
+
* Same contract as the Express adapter and the same reasoning: it scores,
|
|
7
|
+
* attaches, and enforces nothing. Fastify is not a dependency and its types are
|
|
8
|
+
* not imported — the four properties used are declared below, so this works
|
|
9
|
+
* against Fastify 4 and 5 alike.
|
|
10
|
+
*
|
|
11
|
+
* A `preHandler` rather than a plugin, deliberately. A plugin would have to
|
|
12
|
+
* decorate the request and register hooks for routes it cannot know about; a
|
|
13
|
+
* preHandler is attached to the one route that needs it, which is where the
|
|
14
|
+
* decision about what to score belongs.
|
|
15
|
+
*/
|
|
16
|
+
export interface FastifyRequestLike {
|
|
17
|
+
readonly body?: unknown;
|
|
18
|
+
readonly ip?: string;
|
|
19
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface ShieldPreHandlerOptions {
|
|
23
|
+
readonly action: string;
|
|
24
|
+
readonly token?: (req: FastifyRequestLike) => string | undefined;
|
|
25
|
+
readonly keys?: (req: FastifyRequestLike) => EvaluateInput['keys'];
|
|
26
|
+
/** Where to hang the verdict. `request.shield` by default. */
|
|
27
|
+
readonly property?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* ```ts
|
|
31
|
+
* fastify.post('/login', { preHandler: shieldFastify(shield, { action: 'login' }) }, async (req, reply) => {
|
|
32
|
+
* const verdict = (req as { shield: Verdict }).shield
|
|
33
|
+
* if (verdict.decision === 'deny') return reply.code(401).send({ error: 'try again' })
|
|
34
|
+
* …
|
|
35
|
+
* })
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare function shieldFastify(shield: ShieldClient, options: ShieldPreHandlerOptions): (req: FastifyRequestLike) => Promise<void>;
|
|
39
|
+
//# sourceMappingURL=fastify.d.ts.map
|
package/dist/fastify.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { stringHeaders } from './express.js';
|
|
2
|
+
/**
|
|
3
|
+
* ```ts
|
|
4
|
+
* fastify.post('/login', { preHandler: shieldFastify(shield, { action: 'login' }) }, async (req, reply) => {
|
|
5
|
+
* const verdict = (req as { shield: Verdict }).shield
|
|
6
|
+
* if (verdict.decision === 'deny') return reply.code(401).send({ error: 'try again' })
|
|
7
|
+
* …
|
|
8
|
+
* })
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export function shieldFastify(shield, options) {
|
|
12
|
+
const property = options.property ?? 'shield';
|
|
13
|
+
return async (req) => {
|
|
14
|
+
const token = options.token !== undefined ? options.token(req) : bodyToken(req);
|
|
15
|
+
const verdict = await shield.evaluate({
|
|
16
|
+
action: options.action,
|
|
17
|
+
...(token === undefined ? {} : { token }),
|
|
18
|
+
...(options.keys === undefined ? {} : { keys: options.keys(req) }),
|
|
19
|
+
...(req.ip === undefined ? {} : { ip: req.ip }),
|
|
20
|
+
network: { headers: stringHeaders(req.headers) },
|
|
21
|
+
});
|
|
22
|
+
req[property] = verdict;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function bodyToken(req) {
|
|
26
|
+
const body = req.body;
|
|
27
|
+
return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=fastify.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createClient, ShieldClient, API_KEY_METADATA } from './client.js';
|
|
2
|
+
export type { ShieldConfig, ShieldDefaults } from './client.js';
|
|
3
|
+
export { passwordPrefix, isPasswordPrefix } from './password.js';
|
|
4
|
+
export { Breaker } from './breaker.js';
|
|
5
|
+
export type { BreakerOptions } from './breaker.js';
|
|
6
|
+
export type { ChallengeOffer, ChallengeSolution, DecisionName, EvaluateInput, FeedbackInput, Identifiers, LimitCheckInput, LimitState, LimitVerdict, NetworkContext, OutcomeDetails, OutcomeName, Reason, TrustDeviceInput, TrustedDevice, Verdict, WouldHave, } from './types.js';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
package/dist/nest.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ShieldClient } from './client.js';
|
|
2
|
+
import type { EvaluateInput } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A NestJS guard.
|
|
5
|
+
*
|
|
6
|
+
* Nest is not a dependency and there are no decorators here, which is
|
|
7
|
+
* deliberate rather than a shortcut: a guard is structurally just an object
|
|
8
|
+
* with `canActivate`, and `@nestjs/common` would add a large dependency to buy
|
|
9
|
+
* a decorator the integrator can apply themselves with `@UseGuards(...)`.
|
|
10
|
+
*
|
|
11
|
+
* **It always returns true.** A Nest guard that returned false would send a
|
|
12
|
+
* 403 the integrator did not choose, which is precisely the enforcement this
|
|
13
|
+
* product leaves to them. The guard's job is to put the verdict on the request;
|
|
14
|
+
* the controller decides.
|
|
15
|
+
*/
|
|
16
|
+
interface ExecutionContextLike {
|
|
17
|
+
switchToHttp(): {
|
|
18
|
+
getRequest<T = Record<string, unknown>>(): T;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface ShieldGuardOptions {
|
|
22
|
+
readonly action: string;
|
|
23
|
+
readonly token?: (req: Record<string, unknown>) => string | undefined;
|
|
24
|
+
readonly keys?: (req: Record<string, unknown>) => EvaluateInput['keys'];
|
|
25
|
+
/** Where to hang the verdict. `request.shield` by default. */
|
|
26
|
+
readonly property?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ShieldGuard {
|
|
29
|
+
canActivate(context: ExecutionContextLike): Promise<boolean>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* ```ts
|
|
33
|
+
* const guard = createShieldGuard(shield, { action: 'login' })
|
|
34
|
+
*
|
|
35
|
+
* @UseGuards(guard)
|
|
36
|
+
* @Post('login')
|
|
37
|
+
* async login(@Req() req: Request & { shield: Verdict }) {
|
|
38
|
+
* if (req.shield.decision === 'deny') throw new UnauthorizedException()
|
|
39
|
+
* …
|
|
40
|
+
* }
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function createShieldGuard(shield: ShieldClient, options: ShieldGuardOptions): ShieldGuard;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=nest.d.ts.map
|
package/dist/nest.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { stringHeaders } from './express.js';
|
|
2
|
+
/**
|
|
3
|
+
* ```ts
|
|
4
|
+
* const guard = createShieldGuard(shield, { action: 'login' })
|
|
5
|
+
*
|
|
6
|
+
* @UseGuards(guard)
|
|
7
|
+
* @Post('login')
|
|
8
|
+
* async login(@Req() req: Request & { shield: Verdict }) {
|
|
9
|
+
* if (req.shield.decision === 'deny') throw new UnauthorizedException()
|
|
10
|
+
* …
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function createShieldGuard(shield, options) {
|
|
15
|
+
const property = options.property ?? 'shield';
|
|
16
|
+
return {
|
|
17
|
+
async canActivate(context) {
|
|
18
|
+
const req = context.switchToHttp().getRequest();
|
|
19
|
+
const headers = (req['headers'] ?? {});
|
|
20
|
+
const ip = typeof req['ip'] === 'string' ? req['ip'] : undefined;
|
|
21
|
+
const token = options.token !== undefined ? options.token(req) : bodyToken(req);
|
|
22
|
+
const verdict = await shield.evaluate({
|
|
23
|
+
action: options.action,
|
|
24
|
+
...(token === undefined ? {} : { token }),
|
|
25
|
+
...(options.keys === undefined ? {} : { keys: options.keys(req) }),
|
|
26
|
+
...(ip === undefined ? {} : { ip }),
|
|
27
|
+
network: { headers: stringHeaders(headers) },
|
|
28
|
+
});
|
|
29
|
+
req[property] = verdict;
|
|
30
|
+
// Never false. See the note above.
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function bodyToken(req) {
|
|
36
|
+
const body = req['body'];
|
|
37
|
+
return typeof body?.shieldToken === 'string' ? body.shieldToken : undefined;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=nest.js.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first five hex characters of a password's SHA-1.
|
|
3
|
+
*
|
|
4
|
+
* `docs/rate-limiting.md` §175 promises this helper "so nobody hand-rolls the
|
|
5
|
+
* computation", and hand-rolling it is genuinely dangerous: the whole point is
|
|
6
|
+
* that five characters identify a *campaign* and identify no *person*. Send six
|
|
7
|
+
* and the space narrows by sixteen; send the full digest and you have shipped a
|
|
8
|
+
* password hash to a service that never asked for one.
|
|
9
|
+
*
|
|
10
|
+
* It is k-anonymity, the same construction Have I Been Pwned's range API uses.
|
|
11
|
+
* Five hex characters is one bucket in about a million, which is enough to see
|
|
12
|
+
* one password being sprayed across many accounts and far too coarse to say
|
|
13
|
+
* whose it was.
|
|
14
|
+
*
|
|
15
|
+
* **This is the only place the guardrail exists.** Both this document and the
|
|
16
|
+
* proto comment on `password_sha1_prefix` say the gateway rejects anything that
|
|
17
|
+
* is not exactly five lowercase hex characters. It does not — `services/
|
|
18
|
+
* context.ts` passes the field through on a truthiness check. Until that is
|
|
19
|
+
* fixed server-side, a caller that builds the prefix by hand can send whatever
|
|
20
|
+
* it likes and nothing will stop it.
|
|
21
|
+
*/
|
|
22
|
+
export declare function passwordPrefix(password: string): string;
|
|
23
|
+
/** Whether a value is a prefix this SDK would have produced. */
|
|
24
|
+
export declare function isPasswordPrefix(value: string): boolean;
|
|
25
|
+
//# sourceMappingURL=password.d.ts.map
|
package/dist/password.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* The first five hex characters of a password's SHA-1.
|
|
4
|
+
*
|
|
5
|
+
* `docs/rate-limiting.md` §175 promises this helper "so nobody hand-rolls the
|
|
6
|
+
* computation", and hand-rolling it is genuinely dangerous: the whole point is
|
|
7
|
+
* that five characters identify a *campaign* and identify no *person*. Send six
|
|
8
|
+
* and the space narrows by sixteen; send the full digest and you have shipped a
|
|
9
|
+
* password hash to a service that never asked for one.
|
|
10
|
+
*
|
|
11
|
+
* It is k-anonymity, the same construction Have I Been Pwned's range API uses.
|
|
12
|
+
* Five hex characters is one bucket in about a million, which is enough to see
|
|
13
|
+
* one password being sprayed across many accounts and far too coarse to say
|
|
14
|
+
* whose it was.
|
|
15
|
+
*
|
|
16
|
+
* **This is the only place the guardrail exists.** Both this document and the
|
|
17
|
+
* proto comment on `password_sha1_prefix` say the gateway rejects anything that
|
|
18
|
+
* is not exactly five lowercase hex characters. It does not — `services/
|
|
19
|
+
* context.ts` passes the field through on a truthiness check. Until that is
|
|
20
|
+
* fixed server-side, a caller that builds the prefix by hand can send whatever
|
|
21
|
+
* it likes and nothing will stop it.
|
|
22
|
+
*/
|
|
23
|
+
export function passwordPrefix(password) {
|
|
24
|
+
return createHash('sha1').update(password, 'utf8').digest('hex').slice(0, 5);
|
|
25
|
+
}
|
|
26
|
+
/** Whether a value is a prefix this SDK would have produced. */
|
|
27
|
+
export function isPasswordPrefix(value) {
|
|
28
|
+
return /^[0-9a-f]{5}$/.test(value);
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=password.js.map
|