patchwork-os 1.2.0-beta.2.canary.651 → 1.2.0-beta.2.canary.652
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.
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authentication seam — ADR-0020 Phase A, and the valuable half.
|
|
3
|
+
*
|
|
4
|
+
* One interface that answers "who is making this request?", with Phase A
|
|
5
|
+
* (local scrypt credentials against `members.json`) behind it today and Phase
|
|
6
|
+
* B (OIDC, mapped on `sub`) able to slot in later WITHOUT changing any
|
|
7
|
+
* consumer.
|
|
8
|
+
*
|
|
9
|
+
* Phase B is built in `patchwork-control-plane`, not here — ADR-0019 reserves
|
|
10
|
+
* organisation identity for the non-MIT repo, and the two ADRs were written in
|
|
11
|
+
* the same commit with this collision unexamined. The seam, `UNATTRIBUTED` and
|
|
12
|
+
* the fail-soft roster default are MIT and live here. Federation does not.
|
|
13
|
+
*
|
|
14
|
+
* ## UNATTRIBUTED is a value, not an absence
|
|
15
|
+
*
|
|
16
|
+
* The single most important rule in this file, and the one a reasonable
|
|
17
|
+
* implementation gets wrong: when nobody has authenticated, the answer is
|
|
18
|
+
* `UNATTRIBUTED` — never the implicit owner.
|
|
19
|
+
*
|
|
20
|
+
* The roster fails SOFT: a missing `members.json` yields one implicit owner so
|
|
21
|
+
* a single-user machine keeps working. That is right for "who may act on your
|
|
22
|
+
* own machine". It is catastrophic for "who did this", because defaulting an
|
|
23
|
+
* actor to the implicit owner writes a claim about a real person into an audit
|
|
24
|
+
* record on no evidence. An absent `actor` already means "nobody recorded
|
|
25
|
+
* this" and is never backfilled; a defaulted one is indistinguishable from a
|
|
26
|
+
* recorded one and is a lie the record cannot walk back.
|
|
27
|
+
*
|
|
28
|
+
* So `resolveActor` returns `UNATTRIBUTED` and callers must handle it. There
|
|
29
|
+
* is deliberately no `?? implicitOwner()` anywhere in this module, and a test
|
|
30
|
+
* asserts the string never appears.
|
|
31
|
+
*/
|
|
32
|
+
import type { Member } from "./members.js";
|
|
33
|
+
import { type Roster } from "./roster.js";
|
|
34
|
+
/**
|
|
35
|
+
* Nobody authenticated. Distinct from "authentication failed" only in that
|
|
36
|
+
* neither may ever be turned into a person.
|
|
37
|
+
*/
|
|
38
|
+
export declare const UNATTRIBUTED: "unattributed";
|
|
39
|
+
export type Principal = {
|
|
40
|
+
kind: "member";
|
|
41
|
+
member: Member;
|
|
42
|
+
via: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: typeof UNATTRIBUTED;
|
|
45
|
+
};
|
|
46
|
+
/** Credentials presented by a caller. Extended, never replaced, by Phase B. */
|
|
47
|
+
export interface Presented {
|
|
48
|
+
memberId?: string;
|
|
49
|
+
password?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* One authentication method. Phase A is `LocalPasswordProvider` below; Phase B
|
|
53
|
+
* would add an OIDC provider behind this same shape.
|
|
54
|
+
*
|
|
55
|
+
* Returning `null` means "not my business" — no credentials of my kind were
|
|
56
|
+
* presented. Returning `UNATTRIBUTED` means "mine were, and they were wrong".
|
|
57
|
+
* The distinction matters: the first lets another provider try, the second
|
|
58
|
+
* must not.
|
|
59
|
+
*/
|
|
60
|
+
export interface AuthProvider {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
authenticate(presented: Presented, roster: Roster): Promise<Principal | null>;
|
|
63
|
+
}
|
|
64
|
+
/** Phase A: member id + password, verified against the stored scrypt record. */
|
|
65
|
+
export declare class LocalPasswordProvider implements AuthProvider {
|
|
66
|
+
private readonly credentialFor;
|
|
67
|
+
readonly name = "local-password";
|
|
68
|
+
/**
|
|
69
|
+
* `credentialFor` is injected rather than read from the Member type, because
|
|
70
|
+
* where a hash is STORED is a separate decision from how it is verified —
|
|
71
|
+
* and putting a password hash on the in-memory `Member` that decision
|
|
72
|
+
* records copy from is how a hash ends up in an audit log.
|
|
73
|
+
*/
|
|
74
|
+
constructor(credentialFor: (memberId: string) => string | undefined);
|
|
75
|
+
authenticate(presented: Presented, roster: Roster): Promise<Principal | null>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Try each provider in order; the first that claims the request decides.
|
|
79
|
+
*
|
|
80
|
+
* With no providers, or none claiming it, the answer is `UNATTRIBUTED`. That
|
|
81
|
+
* is the byte-identical status quo for a single-user machine with no
|
|
82
|
+
* credentials configured: nothing is denied here, because this module answers
|
|
83
|
+
* WHO, not WHETHER.
|
|
84
|
+
*/
|
|
85
|
+
export declare function resolveActor(presented: Presented, roster: Roster, providers: readonly AuthProvider[]): Promise<Principal>;
|
|
86
|
+
/**
|
|
87
|
+
* The actor snapshot to stamp on a decision record, or undefined.
|
|
88
|
+
*
|
|
89
|
+
* Undefined, NOT a placeholder person. Decision records store the actor as a
|
|
90
|
+
* snapshot (id + kind + display name as it was) so a later rename cannot
|
|
91
|
+
* rewrite history; absence stays absence.
|
|
92
|
+
*/
|
|
93
|
+
export declare function actorSnapshot(principal: Principal): {
|
|
94
|
+
id: string;
|
|
95
|
+
kind: Member["kind"];
|
|
96
|
+
displayName: string;
|
|
97
|
+
} | undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authentication seam — ADR-0020 Phase A, and the valuable half.
|
|
3
|
+
*
|
|
4
|
+
* One interface that answers "who is making this request?", with Phase A
|
|
5
|
+
* (local scrypt credentials against `members.json`) behind it today and Phase
|
|
6
|
+
* B (OIDC, mapped on `sub`) able to slot in later WITHOUT changing any
|
|
7
|
+
* consumer.
|
|
8
|
+
*
|
|
9
|
+
* Phase B is built in `patchwork-control-plane`, not here — ADR-0019 reserves
|
|
10
|
+
* organisation identity for the non-MIT repo, and the two ADRs were written in
|
|
11
|
+
* the same commit with this collision unexamined. The seam, `UNATTRIBUTED` and
|
|
12
|
+
* the fail-soft roster default are MIT and live here. Federation does not.
|
|
13
|
+
*
|
|
14
|
+
* ## UNATTRIBUTED is a value, not an absence
|
|
15
|
+
*
|
|
16
|
+
* The single most important rule in this file, and the one a reasonable
|
|
17
|
+
* implementation gets wrong: when nobody has authenticated, the answer is
|
|
18
|
+
* `UNATTRIBUTED` — never the implicit owner.
|
|
19
|
+
*
|
|
20
|
+
* The roster fails SOFT: a missing `members.json` yields one implicit owner so
|
|
21
|
+
* a single-user machine keeps working. That is right for "who may act on your
|
|
22
|
+
* own machine". It is catastrophic for "who did this", because defaulting an
|
|
23
|
+
* actor to the implicit owner writes a claim about a real person into an audit
|
|
24
|
+
* record on no evidence. An absent `actor` already means "nobody recorded
|
|
25
|
+
* this" and is never backfilled; a defaulted one is indistinguishable from a
|
|
26
|
+
* recorded one and is a lie the record cannot walk back.
|
|
27
|
+
*
|
|
28
|
+
* So `resolveActor` returns `UNATTRIBUTED` and callers must handle it. There
|
|
29
|
+
* is deliberately no `?? implicitOwner()` anywhere in this module, and a test
|
|
30
|
+
* asserts the string never appears.
|
|
31
|
+
*/
|
|
32
|
+
import { verifyPassword } from "./credentials.js";
|
|
33
|
+
import { findMember } from "./roster.js";
|
|
34
|
+
/**
|
|
35
|
+
* Nobody authenticated. Distinct from "authentication failed" only in that
|
|
36
|
+
* neither may ever be turned into a person.
|
|
37
|
+
*/
|
|
38
|
+
export const UNATTRIBUTED = "unattributed";
|
|
39
|
+
/** Phase A: member id + password, verified against the stored scrypt record. */
|
|
40
|
+
export class LocalPasswordProvider {
|
|
41
|
+
credentialFor;
|
|
42
|
+
name = "local-password";
|
|
43
|
+
/**
|
|
44
|
+
* `credentialFor` is injected rather than read from the Member type, because
|
|
45
|
+
* where a hash is STORED is a separate decision from how it is verified —
|
|
46
|
+
* and putting a password hash on the in-memory `Member` that decision
|
|
47
|
+
* records copy from is how a hash ends up in an audit log.
|
|
48
|
+
*/
|
|
49
|
+
constructor(credentialFor) {
|
|
50
|
+
this.credentialFor = credentialFor;
|
|
51
|
+
}
|
|
52
|
+
async authenticate(presented, roster) {
|
|
53
|
+
const { memberId, password } = presented;
|
|
54
|
+
if (!memberId || !password)
|
|
55
|
+
return null; // not our business
|
|
56
|
+
const member = findMember(roster, memberId);
|
|
57
|
+
const record = this.credentialFor(memberId);
|
|
58
|
+
// Both branches do the same work whether or not the member exists, so
|
|
59
|
+
// "no such member" and "wrong password" cost the same. Otherwise the
|
|
60
|
+
// response time enumerates the roster.
|
|
61
|
+
const stored = record ?? DUMMY_RECORD;
|
|
62
|
+
const ok = await verifyPassword(password, stored);
|
|
63
|
+
if (!ok || !member || !member.active || !record) {
|
|
64
|
+
// A deactivated member keeps their record and history and may do
|
|
65
|
+
// nothing — including authenticate.
|
|
66
|
+
return { kind: UNATTRIBUTED };
|
|
67
|
+
}
|
|
68
|
+
return { kind: "member", member, via: this.name };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* A syntactically valid record no password verifies against, so the unknown-
|
|
73
|
+
* member path performs a real scrypt derivation instead of returning early.
|
|
74
|
+
* The salt and hash are fixed and meaningless; the point is the CPU time.
|
|
75
|
+
*/
|
|
76
|
+
const DUMMY_RECORD = "scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$" +
|
|
77
|
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
78
|
+
/**
|
|
79
|
+
* Try each provider in order; the first that claims the request decides.
|
|
80
|
+
*
|
|
81
|
+
* With no providers, or none claiming it, the answer is `UNATTRIBUTED`. That
|
|
82
|
+
* is the byte-identical status quo for a single-user machine with no
|
|
83
|
+
* credentials configured: nothing is denied here, because this module answers
|
|
84
|
+
* WHO, not WHETHER.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveActor(presented, roster, providers) {
|
|
87
|
+
for (const p of providers) {
|
|
88
|
+
const result = await p.authenticate(presented, roster);
|
|
89
|
+
if (result !== null)
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
return { kind: UNATTRIBUTED };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The actor snapshot to stamp on a decision record, or undefined.
|
|
96
|
+
*
|
|
97
|
+
* Undefined, NOT a placeholder person. Decision records store the actor as a
|
|
98
|
+
* snapshot (id + kind + display name as it was) so a later rename cannot
|
|
99
|
+
* rewrite history; absence stays absence.
|
|
100
|
+
*/
|
|
101
|
+
export function actorSnapshot(principal) {
|
|
102
|
+
if (principal.kind === UNATTRIBUTED)
|
|
103
|
+
return undefined;
|
|
104
|
+
const { id, kind, displayName } = principal.member;
|
|
105
|
+
return { id, kind, displayName };
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=authSeam.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"authSeam.js","sourceRoot":"","sources":["../../src/identity/authSeam.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAElD,OAAO,EAAE,UAAU,EAAe,MAAM,aAAa,CAAC;AAEtD;;;GAGG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,cAAuB,CAAC;AA0BpD,gFAAgF;AAChF,MAAM,OAAO,qBAAqB;IAUb;IATV,IAAI,GAAG,gBAAgB,CAAC;IAEjC;;;;;OAKG;IACH,YACmB,aAAuD;QAAvD,kBAAa,GAAb,aAAa,CAA0C;IACvE,CAAC;IAEJ,KAAK,CAAC,YAAY,CAChB,SAAoB,EACpB,MAAc;QAEd,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,SAAS,CAAC;QACzC,IAAI,CAAC,QAAQ,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC,CAAC,mBAAmB;QAE5D,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QAC5C,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;QAE5C,sEAAsE;QACtE,qEAAqE;QACrE,uCAAuC;QACvC,MAAM,MAAM,GAAG,MAAM,IAAI,YAAY,CAAC;QACtC,MAAM,EAAE,GAAG,MAAM,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAElD,IAAI,CAAC,EAAE,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;YAChD,iEAAiE;YACjE,oCAAoC;YACpC,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;QAChC,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACpD,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,YAAY,GAChB,4CAA4C;IAC5C,sFAAsF,CAAC;AAEzF;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,SAAoB,EACpB,MAAc,EACd,SAAkC;IAElC,KAAK,MAAM,CAAC,IAAI,SAAS,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,YAAY,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;QACvD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,MAAM,CAAC;IACrC,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;AAChC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAC3B,SAAoB;IAEpB,IAAI,SAAS,CAAC,IAAI,KAAK,YAAY;QAAE,OAAO,SAAS,CAAC;IACtD,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC,MAAM,CAAC;IACnD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;AACnC,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-member local credentials — ADR-0020 Phase A.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `canApproveAction` is referenced by nothing in production, and not because
|
|
7
|
+
* anyone forgot. The bridge authenticates ONE shared bearer token, and the
|
|
8
|
+
* dashboard session cookie's signed payload is literally `v1.${expiresAt}` —
|
|
9
|
+
* not "no subject field" but no field except expiry. Every approver is
|
|
10
|
+
* indistinguishable at the auth layer, so segregation of duties is
|
|
11
|
+
* unenforceable rather than merely unimplemented, and no decision record can
|
|
12
|
+
* honestly name a person.
|
|
13
|
+
*
|
|
14
|
+
* ## `crypto.scrypt`, from the standard library
|
|
15
|
+
*
|
|
16
|
+
* The dependency audit is unambiguous: no bcrypt, argon2, passport, next-auth
|
|
17
|
+
* or jose in this tree, direct or dev. Adding a native-compilation
|
|
18
|
+
* password-hashing dependency to a project that installs globally on macOS,
|
|
19
|
+
* Linux and Windows — and which already documents a macOS TCC symlink footgun
|
|
20
|
+
* around global installs — buys a marginally better KDF for a real
|
|
21
|
+
* cross-platform install risk. scrypt is memory-hard, in the standard library,
|
|
22
|
+
* and present in every runtime the bridge already targets.
|
|
23
|
+
*
|
|
24
|
+
* ## What this module does NOT do
|
|
25
|
+
*
|
|
26
|
+
* It does not read the roster, does not decide authorisation, and does not
|
|
27
|
+
* touch the dashboard cookie. Verifying a secret and deciding what somebody
|
|
28
|
+
* may do are different questions, and the cookie change touches ten consumers
|
|
29
|
+
* and wants its own review. This is the half with no blast radius.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* scrypt parameters, stored per-hash rather than assumed.
|
|
33
|
+
*
|
|
34
|
+
* Encoding them in the record is what makes them changeable later: a stored
|
|
35
|
+
* hash that does not say how it was derived can only be verified by a
|
|
36
|
+
* constant, so raising the cost would silently invalidate every existing
|
|
37
|
+
* credential instead of upgrading it.
|
|
38
|
+
*
|
|
39
|
+
* N=2^15 with r=8, p=1 is ~32 MB of memory per verification. `maxmem` must be
|
|
40
|
+
* raised above Node's 32 MB default or scrypt throws at these parameters —
|
|
41
|
+
* a footgun that presents as "the correct password is rejected".
|
|
42
|
+
*/
|
|
43
|
+
export declare const SCRYPT_PARAMS: {
|
|
44
|
+
readonly N: 32768;
|
|
45
|
+
readonly r: 8;
|
|
46
|
+
readonly p: 1;
|
|
47
|
+
};
|
|
48
|
+
/** Serialised credential: `scrypt$N$r$p$<saltB64>$<hashB64>`. */
|
|
49
|
+
export type CredentialRecord = string;
|
|
50
|
+
/** Hash a password for storage. A fresh 16-byte salt per call. */
|
|
51
|
+
export declare function hashPassword(password: string, params?: {
|
|
52
|
+
N: number;
|
|
53
|
+
r: number;
|
|
54
|
+
p: number;
|
|
55
|
+
}): Promise<CredentialRecord>;
|
|
56
|
+
/**
|
|
57
|
+
* Verify a password against a stored record.
|
|
58
|
+
*
|
|
59
|
+
* Returns false for a malformed record rather than throwing: a corrupt
|
|
60
|
+
* credential must read as "this password is wrong", not as an exception a
|
|
61
|
+
* caller might catch and treat as a pass. It is the same fail-closed reasoning
|
|
62
|
+
* as ADR-0016 — deciding whether an action happens defaults to no.
|
|
63
|
+
*
|
|
64
|
+
* The comparison is `timingSafeEqual` on the derived key. Lengths are checked
|
|
65
|
+
* first because it throws on a length mismatch, and a thrown comparison is a
|
|
66
|
+
* timing signal of its own.
|
|
67
|
+
*/
|
|
68
|
+
export declare function verifyPassword(password: string, record: CredentialRecord): Promise<boolean>;
|
|
69
|
+
/** True when a string is a well-formed credential record. */
|
|
70
|
+
export declare function isCredentialRecord(value: unknown): value is CredentialRecord;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-member local credentials — ADR-0020 Phase A.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `canApproveAction` is referenced by nothing in production, and not because
|
|
7
|
+
* anyone forgot. The bridge authenticates ONE shared bearer token, and the
|
|
8
|
+
* dashboard session cookie's signed payload is literally `v1.${expiresAt}` —
|
|
9
|
+
* not "no subject field" but no field except expiry. Every approver is
|
|
10
|
+
* indistinguishable at the auth layer, so segregation of duties is
|
|
11
|
+
* unenforceable rather than merely unimplemented, and no decision record can
|
|
12
|
+
* honestly name a person.
|
|
13
|
+
*
|
|
14
|
+
* ## `crypto.scrypt`, from the standard library
|
|
15
|
+
*
|
|
16
|
+
* The dependency audit is unambiguous: no bcrypt, argon2, passport, next-auth
|
|
17
|
+
* or jose in this tree, direct or dev. Adding a native-compilation
|
|
18
|
+
* password-hashing dependency to a project that installs globally on macOS,
|
|
19
|
+
* Linux and Windows — and which already documents a macOS TCC symlink footgun
|
|
20
|
+
* around global installs — buys a marginally better KDF for a real
|
|
21
|
+
* cross-platform install risk. scrypt is memory-hard, in the standard library,
|
|
22
|
+
* and present in every runtime the bridge already targets.
|
|
23
|
+
*
|
|
24
|
+
* ## What this module does NOT do
|
|
25
|
+
*
|
|
26
|
+
* It does not read the roster, does not decide authorisation, and does not
|
|
27
|
+
* touch the dashboard cookie. Verifying a secret and deciding what somebody
|
|
28
|
+
* may do are different questions, and the cookie change touches ten consumers
|
|
29
|
+
* and wants its own review. This is the half with no blast radius.
|
|
30
|
+
*/
|
|
31
|
+
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
const scrypt = promisify(scryptCb);
|
|
34
|
+
/**
|
|
35
|
+
* scrypt parameters, stored per-hash rather than assumed.
|
|
36
|
+
*
|
|
37
|
+
* Encoding them in the record is what makes them changeable later: a stored
|
|
38
|
+
* hash that does not say how it was derived can only be verified by a
|
|
39
|
+
* constant, so raising the cost would silently invalidate every existing
|
|
40
|
+
* credential instead of upgrading it.
|
|
41
|
+
*
|
|
42
|
+
* N=2^15 with r=8, p=1 is ~32 MB of memory per verification. `maxmem` must be
|
|
43
|
+
* raised above Node's 32 MB default or scrypt throws at these parameters —
|
|
44
|
+
* a footgun that presents as "the correct password is rejected".
|
|
45
|
+
*/
|
|
46
|
+
export const SCRYPT_PARAMS = { N: 32768, r: 8, p: 1 };
|
|
47
|
+
const KEYLEN = 64;
|
|
48
|
+
const MAXMEM = 192 * 1024 * 1024;
|
|
49
|
+
const PREFIX = "scrypt";
|
|
50
|
+
/** Hash a password for storage. A fresh 16-byte salt per call. */
|
|
51
|
+
export async function hashPassword(password, params = SCRYPT_PARAMS) {
|
|
52
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
53
|
+
throw new Error("password must be a non-empty string");
|
|
54
|
+
}
|
|
55
|
+
const salt = randomBytes(16);
|
|
56
|
+
const derived = await scrypt(password, salt, KEYLEN, {
|
|
57
|
+
...params,
|
|
58
|
+
maxmem: MAXMEM,
|
|
59
|
+
});
|
|
60
|
+
return [
|
|
61
|
+
PREFIX,
|
|
62
|
+
params.N,
|
|
63
|
+
params.r,
|
|
64
|
+
params.p,
|
|
65
|
+
salt.toString("base64"),
|
|
66
|
+
derived.toString("base64"),
|
|
67
|
+
].join("$");
|
|
68
|
+
}
|
|
69
|
+
/** Parsed form of a stored record, or null when it is not one. */
|
|
70
|
+
function parseRecord(record) {
|
|
71
|
+
if (typeof record !== "string")
|
|
72
|
+
return null;
|
|
73
|
+
const parts = record.split("$");
|
|
74
|
+
if (parts.length !== 6 || parts[0] !== PREFIX)
|
|
75
|
+
return null;
|
|
76
|
+
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
|
|
77
|
+
const N = Number(nRaw);
|
|
78
|
+
const r = Number(rRaw);
|
|
79
|
+
const p = Number(pRaw);
|
|
80
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
// Bound the work a stored record can demand. Without this, anyone who can
|
|
84
|
+
// write members.json can make one verification consume unbounded CPU and
|
|
85
|
+
// memory — a denial of service authored in a config file.
|
|
86
|
+
if (N < 16384 || N > 1 << 20 || r < 1 || r > 32 || p < 1 || p > 16) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
let salt;
|
|
90
|
+
let hash;
|
|
91
|
+
try {
|
|
92
|
+
salt = Buffer.from(saltRaw ?? "", "base64");
|
|
93
|
+
hash = Buffer.from(hashRaw ?? "", "base64");
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (salt.length === 0 || hash.length !== KEYLEN)
|
|
99
|
+
return null;
|
|
100
|
+
return { N, r, p, salt, hash };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Verify a password against a stored record.
|
|
104
|
+
*
|
|
105
|
+
* Returns false for a malformed record rather than throwing: a corrupt
|
|
106
|
+
* credential must read as "this password is wrong", not as an exception a
|
|
107
|
+
* caller might catch and treat as a pass. It is the same fail-closed reasoning
|
|
108
|
+
* as ADR-0016 — deciding whether an action happens defaults to no.
|
|
109
|
+
*
|
|
110
|
+
* The comparison is `timingSafeEqual` on the derived key. Lengths are checked
|
|
111
|
+
* first because it throws on a length mismatch, and a thrown comparison is a
|
|
112
|
+
* timing signal of its own.
|
|
113
|
+
*/
|
|
114
|
+
export async function verifyPassword(password, record) {
|
|
115
|
+
const parsed = parseRecord(record);
|
|
116
|
+
if (!parsed)
|
|
117
|
+
return false;
|
|
118
|
+
if (typeof password !== "string" || password.length === 0)
|
|
119
|
+
return false;
|
|
120
|
+
let derived;
|
|
121
|
+
try {
|
|
122
|
+
derived = await scrypt(password, parsed.salt, KEYLEN, {
|
|
123
|
+
N: parsed.N,
|
|
124
|
+
r: parsed.r,
|
|
125
|
+
p: parsed.p,
|
|
126
|
+
maxmem: MAXMEM,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (derived.length !== parsed.hash.length)
|
|
133
|
+
return false;
|
|
134
|
+
return timingSafeEqual(derived, parsed.hash);
|
|
135
|
+
}
|
|
136
|
+
/** True when a string is a well-formed credential record. */
|
|
137
|
+
export function isCredentialRecord(value) {
|
|
138
|
+
return typeof value === "string" && parseRecord(value) !== null;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/identity/credentials.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchwork-os",
|
|
3
|
-
"version": "1.2.0-beta.2.canary.
|
|
3
|
+
"version": "1.2.0-beta.2.canary.652",
|
|
4
4
|
"description": "Your personal AI runtime, local-first. Patchwork OS gives any AI model a consistent set of tools, YAML recipes, a delegation policy with approval queue, and a durable trace memory — all on your machine, all under your policy.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|