@unchainedshop/core-users 4.4.0 → 4.6.0
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/README.md +31 -0
- package/lib/db/UsersCollection.d.ts +7 -3
- package/lib/db/WebAuthnCredentialsCreationRequestsCollection.d.ts +1 -1
- package/lib/migrations/20241218092300-convert-locale.d.ts +2 -0
- package/lib/migrations/20241218092300-convert-locale.js +36 -0
- package/lib/module/configureUsersModule.d.ts +53 -18
- package/lib/module/configureUsersModule.js +137 -5
- package/lib/module/configureUsersWebAuthnModule.d.ts +80 -20
- package/lib/module/configureUsersWebAuthnModule.js +129 -108
- package/lib/module/pbkdf2.js +2 -1
- package/lib/utils/web3-verification.d.ts +1 -0
- package/lib/utils/web3-verification.js +81 -0
- package/package.json +20 -10
package/README.md
CHANGED
|
@@ -120,6 +120,37 @@ await usersModule.updateProfile(userId, {
|
|
|
120
120
|
| `USER_UPDATE_ROLES` | Roles changed |
|
|
121
121
|
| `USER_ACCOUNT_ACTION` | Account action triggered |
|
|
122
122
|
|
|
123
|
+
## Security
|
|
124
|
+
|
|
125
|
+
This module implements security best practices for user authentication and data protection.
|
|
126
|
+
|
|
127
|
+
### Password Security
|
|
128
|
+
|
|
129
|
+
- **Algorithm**: PBKDF2 with SHA-512
|
|
130
|
+
- **Iterations**: 300,000 (exceeds OWASP recommendation)
|
|
131
|
+
- **Salt**: 16 bytes, cryptographically random per password
|
|
132
|
+
- **Key Length**: 256 bytes
|
|
133
|
+
- **FIPS 140-3**: Compatible when running on FIPS-enabled Node.js
|
|
134
|
+
|
|
135
|
+
### Token Security
|
|
136
|
+
|
|
137
|
+
- **Generation**: `crypto.randomUUID()` (CSPRNG-based)
|
|
138
|
+
- **Storage**: SHA-256 hashed before database storage
|
|
139
|
+
- **Expiration**: Time-limited (configurable, default 1 hour)
|
|
140
|
+
- **Single-use**: Tokens invalidated after verification
|
|
141
|
+
|
|
142
|
+
### WebAuthn/FIDO2
|
|
143
|
+
|
|
144
|
+
Full support for passwordless authentication via hardware security keys and platform authenticators, providing phishing-resistant authentication.
|
|
145
|
+
|
|
146
|
+
### Data Protection
|
|
147
|
+
|
|
148
|
+
- Sensitive data (password hashes, tokens) stripped from event emissions via `removeConfidentialServiceHashes()`
|
|
149
|
+
- Soft delete preserves audit trail while removing PII
|
|
150
|
+
- Email addresses and profile data access-controlled via RBAC
|
|
151
|
+
|
|
152
|
+
See [SECURITY.md](../../SECURITY.md) for complete security documentation.
|
|
153
|
+
|
|
123
154
|
## License
|
|
124
155
|
|
|
125
156
|
EUPL-1.2
|
|
@@ -30,7 +30,7 @@ export interface Email {
|
|
|
30
30
|
}
|
|
31
31
|
export type User = {
|
|
32
32
|
_id: string;
|
|
33
|
-
deleted?: Date;
|
|
33
|
+
deleted?: Date | null;
|
|
34
34
|
avatarId?: string;
|
|
35
35
|
emails: Email[];
|
|
36
36
|
guest: boolean;
|
|
@@ -46,11 +46,15 @@ export type User = {
|
|
|
46
46
|
username?: string;
|
|
47
47
|
meta?: any;
|
|
48
48
|
} & TimestampFields;
|
|
49
|
-
export
|
|
49
|
+
export interface UserQuery {
|
|
50
50
|
includeGuests?: boolean;
|
|
51
51
|
includeDeleted?: boolean;
|
|
52
52
|
queryString?: string;
|
|
53
53
|
emailVerified?: boolean;
|
|
54
54
|
lastLogin?: DateFilterInput;
|
|
55
|
-
|
|
55
|
+
tags?: string[];
|
|
56
|
+
userIds?: string[];
|
|
57
|
+
username?: string;
|
|
58
|
+
web3Verified?: boolean;
|
|
59
|
+
}
|
|
56
60
|
export declare const UsersCollection: (db: mongodb.Db) => Promise<mongodb.Collection<User>>;
|
|
@@ -6,7 +6,7 @@ export interface WebAuthnCredentialsCreationRequest {
|
|
|
6
6
|
factor: 'first' | 'second' | 'either';
|
|
7
7
|
}
|
|
8
8
|
type Collection = WebAuthnCredentialsCreationRequest & {
|
|
9
|
-
_id:
|
|
9
|
+
_id: string;
|
|
10
10
|
};
|
|
11
11
|
export declare const WebAuthnCredentialsCreationRequestsCollection: (db: mongodb.Db) => Promise<mongodb.Collection<Collection>>;
|
|
12
12
|
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { UsersCollection } from "../db/UsersCollection.js";
|
|
2
|
+
import { systemLocale } from '@unchainedshop/utils';
|
|
3
|
+
export default function convertUserLocale(repository) {
|
|
4
|
+
repository?.register({
|
|
5
|
+
id: 20241218092300,
|
|
6
|
+
name: 'Convert user.lastLogin.locale',
|
|
7
|
+
up: async () => {
|
|
8
|
+
const Users = await UsersCollection(repository.db);
|
|
9
|
+
const users = await Users.find({
|
|
10
|
+
'lastLogin.locale': { $exists: true },
|
|
11
|
+
}, { projection: { _id: true, lastLogin: true } }).toArray();
|
|
12
|
+
for (const user of users) {
|
|
13
|
+
let newLocale;
|
|
14
|
+
const currentLocale = user.lastLogin?.locale;
|
|
15
|
+
if (!currentLocale)
|
|
16
|
+
continue;
|
|
17
|
+
try {
|
|
18
|
+
newLocale = new Intl.Locale(currentLocale).baseName;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
try {
|
|
22
|
+
newLocale = new Intl.Locale(currentLocale.split('_').join('-')).baseName;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
newLocale = systemLocale.baseName;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await Users.updateOne({
|
|
29
|
+
_id: user._id,
|
|
30
|
+
}, {
|
|
31
|
+
$set: { 'lastLogin.locale': newLocale },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -3,27 +3,40 @@ import { type User, type UserQuery, type Email, type UserLastLogin, type UserPro
|
|
|
3
3
|
import { type SortOption } from '@unchainedshop/utils';
|
|
4
4
|
import { type UserRegistrationData, type UserSettingsOptions } from '../users-settings.ts';
|
|
5
5
|
export declare const removeConfidentialServiceHashes: (rawUser: User) => User;
|
|
6
|
-
export declare const buildFindSelector: ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags,
|
|
6
|
+
export declare const buildFindSelector: ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, web3Verified, }: UserQuery) => mongodb.Filter<User>;
|
|
7
7
|
export declare const configureUsersModule: (moduleInput: ModuleInput<UserSettingsOptions>) => Promise<{
|
|
8
8
|
webAuthn: {
|
|
9
|
-
findMDSMetadataForAAGUID: (aaguid: string) => Promise<
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
verifyCredentialCreation: (username: string, credentials: any) => Promise<{
|
|
19
|
-
publicKey: any;
|
|
20
|
-
counter: any;
|
|
21
|
-
id: any;
|
|
22
|
-
aaguid: string;
|
|
23
|
-
created: Date;
|
|
9
|
+
findMDSMetadataForAAGUID: (aaguid: string) => Promise<{
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
description?: string;
|
|
12
|
+
icon?: string;
|
|
13
|
+
authenticatorGetInfo?: {
|
|
14
|
+
versions?: string[];
|
|
15
|
+
extensions?: string[];
|
|
16
|
+
options?: Record<string, boolean>;
|
|
17
|
+
};
|
|
24
18
|
} | null>;
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
createCredentialCreationOptions: (origin: string, username: string, extensionOptions?: {
|
|
20
|
+
timeout?: number;
|
|
21
|
+
authenticatorSelection?: import("./configureUsersWebAuthnModule.ts").WebAuthnCredentialCreationOptions["authenticatorSelection"];
|
|
22
|
+
}) => Promise<import("./configureUsersWebAuthnModule.ts").WebAuthnCredentialCreationOptions>;
|
|
23
|
+
createCredentialRequestOptions: (origin: string, username: string, extensionOptions?: {
|
|
24
|
+
timeout?: number;
|
|
25
|
+
userVerification?: "required" | "preferred" | "discouraged";
|
|
26
|
+
allowCredentials?: import("./configureUsersWebAuthnModule.ts").WebAuthnCredentialRequestOptions["allowCredentials"];
|
|
27
|
+
}) => Promise<import("./configureUsersWebAuthnModule.ts").WebAuthnCredentialRequestOptions>;
|
|
28
|
+
verifyCredentialCreation: (username: string, credentials: import("@passwordless-id/webauthn/dist/esm/types.js").RegistrationJSON) => Promise<import("./configureUsersWebAuthnModule.ts").WebAuthnCredential | null>;
|
|
29
|
+
verifyCredentialRequest: (userPublicKeys: {
|
|
30
|
+
id: string;
|
|
31
|
+
publicKey: string;
|
|
32
|
+
algorithm?: import("@passwordless-id/webauthn/dist/esm/types.js").NamedAlgo;
|
|
33
|
+
counter?: number;
|
|
34
|
+
transports?: import("@passwordless-id/webauthn/dist/esm/types.js").ExtendedAuthenticatorTransport[];
|
|
35
|
+
}[], username: string, credentials: import("@passwordless-id/webauthn/dist/esm/types.js").AuthenticationJSON & {
|
|
36
|
+
requestId: string;
|
|
37
|
+
}) => Promise<{
|
|
38
|
+
userHandle: string;
|
|
39
|
+
counter: number;
|
|
27
40
|
} | null>;
|
|
28
41
|
deleteUserWebAuthnCredentials: (username: string) => Promise<number>;
|
|
29
42
|
};
|
|
@@ -69,6 +82,28 @@ export declare const configureUsersModule: (moduleInput: ModuleInput<UserSetting
|
|
|
69
82
|
}, plainPassword: string): Promise<boolean>;
|
|
70
83
|
addEmail(userId: string, address: string): Promise<void>;
|
|
71
84
|
removeEmail(userId: string, address: string): Promise<void>;
|
|
85
|
+
addWeb3Address(userId: string, address: string): Promise<User | null>;
|
|
86
|
+
removeWeb3Address(userId: string, address: string): Promise<User | null>;
|
|
87
|
+
findWeb3Address(user: User, address: string): {
|
|
88
|
+
address: string;
|
|
89
|
+
nonce?: string;
|
|
90
|
+
verified?: boolean;
|
|
91
|
+
} | null;
|
|
92
|
+
addWebAuthnCredential(userId: string, webAuthnService: {
|
|
93
|
+
id: string;
|
|
94
|
+
publicKey: string;
|
|
95
|
+
created: Date;
|
|
96
|
+
}): Promise<User | null>;
|
|
97
|
+
removeWebAuthnCredential(userId: string, credentialsId: string): Promise<User | null>;
|
|
98
|
+
createAccessToken(username: string): Promise<{
|
|
99
|
+
user: User;
|
|
100
|
+
token: string;
|
|
101
|
+
} | null>;
|
|
102
|
+
setAccessToken(username: string, plainSecret: string): Promise<User | null>;
|
|
103
|
+
verifyWeb3SignatureAndUpdate(user: User, credentials: {
|
|
104
|
+
address: string;
|
|
105
|
+
nonce: string;
|
|
106
|
+
}, signature: `0x${string}`): Promise<User | null>;
|
|
72
107
|
sendResetPasswordEmail(userId: string, email: string, isEnrollment?: boolean): Promise<void>;
|
|
73
108
|
sendVerificationEmail(userId: string, email: string): Promise<void>;
|
|
74
109
|
addRoles: (userId: string, roles: string[]) => Promise<mongodb.WithId<User> | null>;
|
|
@@ -6,6 +6,8 @@ import { systemLocale, SortDirection, sha256 } from '@unchainedshop/utils';
|
|
|
6
6
|
import { UserAccountAction, userSettings, } from "../users-settings.js";
|
|
7
7
|
import { configureUsersWebAuthnModule } from "./configureUsersWebAuthnModule.js";
|
|
8
8
|
import * as pbkdf2 from "./pbkdf2.js";
|
|
9
|
+
import { verifyWeb3Signature } from "../utils/web3-verification.js";
|
|
10
|
+
import convertUserLocale from "../migrations/20241218092300-convert-locale.js";
|
|
9
11
|
const USER_EVENTS = [
|
|
10
12
|
'USER_ACCOUNT_ACTION',
|
|
11
13
|
'USER_CREATE',
|
|
@@ -21,6 +23,7 @@ const USER_EVENTS = [
|
|
|
21
23
|
'USER_UPDATE_HEARTBEAT',
|
|
22
24
|
'USER_UPDATE_BILLING_ADDRESS',
|
|
23
25
|
'USER_UPDATE_LAST_CONTACT',
|
|
26
|
+
'USER_UPDATE_WEB3_ADDRESS',
|
|
24
27
|
'USER_REMOVE',
|
|
25
28
|
];
|
|
26
29
|
export const removeConfidentialServiceHashes = (rawUser) => {
|
|
@@ -28,12 +31,18 @@ export const removeConfidentialServiceHashes = (rawUser) => {
|
|
|
28
31
|
delete user?.services;
|
|
29
32
|
return user;
|
|
30
33
|
};
|
|
31
|
-
export const buildFindSelector = ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags,
|
|
32
|
-
const selector = {
|
|
34
|
+
export const buildFindSelector = ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, web3Verified, }) => {
|
|
35
|
+
const selector = {};
|
|
33
36
|
if (!includeDeleted)
|
|
34
37
|
selector.deleted = null;
|
|
35
38
|
if (!includeGuests)
|
|
36
39
|
selector.guest = { $ne: true };
|
|
40
|
+
if (userIds) {
|
|
41
|
+
selector._id = { $in: userIds };
|
|
42
|
+
}
|
|
43
|
+
if (username) {
|
|
44
|
+
selector.username = insensitiveTrimmedRegexOperator(username);
|
|
45
|
+
}
|
|
37
46
|
if (emailVerified === true) {
|
|
38
47
|
selector['emails.verified'] = true;
|
|
39
48
|
}
|
|
@@ -43,6 +52,9 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
|
|
|
43
52
|
if (emailVerified === false) {
|
|
44
53
|
selector['emails.verified'] = { $ne: true };
|
|
45
54
|
}
|
|
55
|
+
if (web3Verified === true) {
|
|
56
|
+
selector['services.web3.verified'] = true;
|
|
57
|
+
}
|
|
46
58
|
if (lastLogin?.start) {
|
|
47
59
|
selector['lastLogin.timestamp'] = { $exists: true };
|
|
48
60
|
}
|
|
@@ -59,7 +71,8 @@ export const buildFindSelector = ({ includeGuests, includeDeleted, queryString,
|
|
|
59
71
|
return selector;
|
|
60
72
|
};
|
|
61
73
|
export const configureUsersModule = async (moduleInput) => {
|
|
62
|
-
const { db, options } = moduleInput;
|
|
74
|
+
const { db, options, migrationRepository } = moduleInput;
|
|
75
|
+
convertUserLocale(migrationRepository);
|
|
63
76
|
userSettings.configureSettings(options || {}, db);
|
|
64
77
|
registerEvents(USER_EVENTS);
|
|
65
78
|
const Users = await UsersCollection(db);
|
|
@@ -176,7 +189,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
176
189
|
}).toArray();
|
|
177
190
|
},
|
|
178
191
|
async userExists({ userId }) {
|
|
179
|
-
const userCount = await Users.countDocuments({ _id: userId, deleted:
|
|
192
|
+
const userCount = await Users.countDocuments({ _id: userId, deleted: null }, { limit: 1 });
|
|
180
193
|
return userCount === 1;
|
|
181
194
|
},
|
|
182
195
|
primaryEmail(user) {
|
|
@@ -196,6 +209,9 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
196
209
|
const { password, email, username, initialPassword, roles, webAuthnPublicKeyCredentials, ...userData } = await userSettings.validateNewUser(rawUserData);
|
|
197
210
|
const webAuthnService = webAuthnPublicKeyCredentials &&
|
|
198
211
|
(await this.webAuthn.verifyCredentialCreation(username, webAuthnPublicKeyCredentials));
|
|
212
|
+
if (webAuthnPublicKeyCredentials && !webAuthnService) {
|
|
213
|
+
throw new Error('WebAuthn credential verification failed', { cause: 'WEBAUTHN_INVALID' });
|
|
214
|
+
}
|
|
199
215
|
const services = {};
|
|
200
216
|
if (email) {
|
|
201
217
|
if (!(await userSettings.validateEmail(email))) {
|
|
@@ -288,6 +304,122 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
288
304
|
},
|
|
289
305
|
});
|
|
290
306
|
},
|
|
307
|
+
async addWeb3Address(userId, address) {
|
|
308
|
+
const user = await Users.findOne(generateDbFilterById(userId), {});
|
|
309
|
+
if (!user)
|
|
310
|
+
return null;
|
|
311
|
+
const existingEntry = user.services?.web3?.find((service) => service.address.toLowerCase() === address.toLowerCase());
|
|
312
|
+
if (existingEntry)
|
|
313
|
+
return user;
|
|
314
|
+
const nonce = crypto.randomUUID();
|
|
315
|
+
const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
316
|
+
$push: {
|
|
317
|
+
'services.web3': {
|
|
318
|
+
address,
|
|
319
|
+
nonce,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
}, { returnDocument: 'after' });
|
|
323
|
+
if (!updatedUser)
|
|
324
|
+
return null;
|
|
325
|
+
await emit('USER_UPDATE_WEB3_ADDRESS', {
|
|
326
|
+
action: 'add',
|
|
327
|
+
address,
|
|
328
|
+
user: removeConfidentialServiceHashes(updatedUser),
|
|
329
|
+
});
|
|
330
|
+
return updatedUser;
|
|
331
|
+
},
|
|
332
|
+
async removeWeb3Address(userId, address) {
|
|
333
|
+
const user = await Users.findOne(generateDbFilterById(userId), {});
|
|
334
|
+
if (!user)
|
|
335
|
+
return null;
|
|
336
|
+
const existingEntry = user.services?.web3?.find((service) => service.address.toLowerCase() === address.toLowerCase());
|
|
337
|
+
if (!existingEntry)
|
|
338
|
+
return null;
|
|
339
|
+
const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
340
|
+
$pull: {
|
|
341
|
+
'services.web3': { address: existingEntry.address },
|
|
342
|
+
},
|
|
343
|
+
}, { returnDocument: 'after' });
|
|
344
|
+
if (!updatedUser)
|
|
345
|
+
return null;
|
|
346
|
+
await emit('USER_UPDATE_WEB3_ADDRESS', {
|
|
347
|
+
action: 'remove',
|
|
348
|
+
address: existingEntry.address,
|
|
349
|
+
user: removeConfidentialServiceHashes(updatedUser),
|
|
350
|
+
});
|
|
351
|
+
return updatedUser;
|
|
352
|
+
},
|
|
353
|
+
findWeb3Address(user, address) {
|
|
354
|
+
return (user.services?.web3?.find((service) => service.address.toLowerCase() === address.toLowerCase()) || null);
|
|
355
|
+
},
|
|
356
|
+
async addWebAuthnCredential(userId, webAuthnService) {
|
|
357
|
+
const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
358
|
+
$push: {
|
|
359
|
+
'services.webAuthn': webAuthnService,
|
|
360
|
+
},
|
|
361
|
+
}, { returnDocument: 'after' });
|
|
362
|
+
return updatedUser;
|
|
363
|
+
},
|
|
364
|
+
async removeWebAuthnCredential(userId, credentialsId) {
|
|
365
|
+
const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(userId), {
|
|
366
|
+
$pull: {
|
|
367
|
+
'services.webAuthn': { id: credentialsId },
|
|
368
|
+
},
|
|
369
|
+
}, { returnDocument: 'after' });
|
|
370
|
+
return updatedUser;
|
|
371
|
+
},
|
|
372
|
+
async createAccessToken(username) {
|
|
373
|
+
const plainToken = crypto.randomUUID();
|
|
374
|
+
const secret = await sha256(plainToken);
|
|
375
|
+
const updatedUser = await Users.findOneAndUpdate({ username: insensitiveTrimmedRegexOperator(username) }, {
|
|
376
|
+
$set: {
|
|
377
|
+
'services.token': { secret },
|
|
378
|
+
},
|
|
379
|
+
}, { returnDocument: 'after' });
|
|
380
|
+
if (!updatedUser)
|
|
381
|
+
return null;
|
|
382
|
+
return { user: updatedUser, token: plainToken };
|
|
383
|
+
},
|
|
384
|
+
async setAccessToken(username, plainSecret) {
|
|
385
|
+
const secret = await sha256(plainSecret);
|
|
386
|
+
const updatedUser = await Users.findOneAndUpdate({ username: insensitiveTrimmedRegexOperator(username) }, {
|
|
387
|
+
$set: {
|
|
388
|
+
'services.token': { secret },
|
|
389
|
+
},
|
|
390
|
+
}, { returnDocument: 'after' });
|
|
391
|
+
return updatedUser;
|
|
392
|
+
},
|
|
393
|
+
async verifyWeb3SignatureAndUpdate(user, credentials, signature) {
|
|
394
|
+
const isValid = await verifyWeb3Signature(credentials.nonce, signature, credentials.address);
|
|
395
|
+
if (!isValid)
|
|
396
|
+
return null;
|
|
397
|
+
const web3Services = user.services?.web3?.map((service) => {
|
|
398
|
+
if (service.address.toLowerCase() === credentials.address.toLowerCase()) {
|
|
399
|
+
return {
|
|
400
|
+
...service,
|
|
401
|
+
nonce: undefined,
|
|
402
|
+
verified: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return service;
|
|
406
|
+
});
|
|
407
|
+
if (!web3Services)
|
|
408
|
+
return null;
|
|
409
|
+
const updatedUser = await Users.findOneAndUpdate(generateDbFilterById(user._id), {
|
|
410
|
+
$set: {
|
|
411
|
+
'services.web3': web3Services,
|
|
412
|
+
},
|
|
413
|
+
}, { returnDocument: 'after' });
|
|
414
|
+
if (!updatedUser)
|
|
415
|
+
return null;
|
|
416
|
+
await emit('USER_UPDATE_WEB3_ADDRESS', {
|
|
417
|
+
action: 'verify',
|
|
418
|
+
address: credentials.address,
|
|
419
|
+
user: removeConfidentialServiceHashes(updatedUser),
|
|
420
|
+
});
|
|
421
|
+
return updatedUser;
|
|
422
|
+
},
|
|
291
423
|
async sendResetPasswordEmail(userId, email, isEnrollment) {
|
|
292
424
|
const plainToken = crypto.randomUUID();
|
|
293
425
|
const resetToken = {
|
|
@@ -636,7 +768,7 @@ export const configureUsersModule = async (moduleInput) => {
|
|
|
636
768
|
existingTags: async () => {
|
|
637
769
|
const tags = (await Users.distinct('tags', {
|
|
638
770
|
tags: { $exists: true },
|
|
639
|
-
deleted:
|
|
771
|
+
deleted: null,
|
|
640
772
|
}));
|
|
641
773
|
return tags.filter(Boolean).toSorted();
|
|
642
774
|
},
|
|
@@ -1,25 +1,85 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type {
|
|
3
|
-
type
|
|
1
|
+
import { type ModuleInput } from '@unchainedshop/mongodb';
|
|
2
|
+
import type { RegistrationJSON, AuthenticationJSON, CredentialInfo, NamedAlgo, ExtendedAuthenticatorTransport } from '@passwordless-id/webauthn/dist/esm/types.js';
|
|
3
|
+
export type { RegistrationJSON, AuthenticationJSON, CredentialInfo, NamedAlgo, ExtendedAuthenticatorTransport, };
|
|
4
|
+
export declare function toArrayBuffer(buffer: Buffer): ArrayBuffer;
|
|
5
|
+
export declare function buf2hex(buffer: ArrayBuffer): string;
|
|
6
|
+
export interface WebAuthnCredentialCreationOptions {
|
|
4
7
|
challenge: string;
|
|
5
|
-
requestId:
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
requestId: string;
|
|
9
|
+
rp: {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
user: {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
displayName: string;
|
|
17
|
+
};
|
|
18
|
+
pubKeyCredParams: {
|
|
19
|
+
type: 'public-key';
|
|
20
|
+
alg: number;
|
|
21
|
+
}[];
|
|
22
|
+
timeout: number;
|
|
23
|
+
attestation: 'none' | 'indirect' | 'direct';
|
|
24
|
+
authenticatorSelection?: {
|
|
25
|
+
authenticatorAttachment?: 'platform' | 'cross-platform';
|
|
26
|
+
requireResidentKey?: boolean;
|
|
27
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface WebAuthnCredentialRequestOptions {
|
|
31
|
+
challenge: string;
|
|
32
|
+
requestId: string;
|
|
33
|
+
rpId: string;
|
|
34
|
+
timeout: number;
|
|
35
|
+
userVerification?: 'required' | 'preferred' | 'discouraged';
|
|
36
|
+
allowCredentials?: {
|
|
37
|
+
id: string;
|
|
38
|
+
type: 'public-key';
|
|
39
|
+
transports?: ('usb' | 'nfc' | 'ble' | 'internal')[];
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
export interface WebAuthnCredential {
|
|
43
|
+
id: string;
|
|
44
|
+
publicKey: string;
|
|
45
|
+
algorithm: NamedAlgo;
|
|
46
|
+
aaguid: string;
|
|
47
|
+
counter: number;
|
|
48
|
+
created: Date;
|
|
49
|
+
}
|
|
50
|
+
export declare const configureUsersWebAuthnModule: ({ db }: ModuleInput<Record<string, any>>) => Promise<{
|
|
51
|
+
findMDSMetadataForAAGUID: (aaguid: string) => Promise<{
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
description?: string;
|
|
54
|
+
icon?: string;
|
|
55
|
+
authenticatorGetInfo?: {
|
|
56
|
+
versions?: string[];
|
|
57
|
+
extensions?: string[];
|
|
58
|
+
options?: Record<string, boolean>;
|
|
59
|
+
};
|
|
19
60
|
} | null>;
|
|
20
|
-
|
|
21
|
-
|
|
61
|
+
createCredentialCreationOptions: (origin: string, username: string, extensionOptions?: {
|
|
62
|
+
timeout?: number;
|
|
63
|
+
authenticatorSelection?: WebAuthnCredentialCreationOptions["authenticatorSelection"];
|
|
64
|
+
}) => Promise<WebAuthnCredentialCreationOptions>;
|
|
65
|
+
createCredentialRequestOptions: (origin: string, username: string, extensionOptions?: {
|
|
66
|
+
timeout?: number;
|
|
67
|
+
userVerification?: "required" | "preferred" | "discouraged";
|
|
68
|
+
allowCredentials?: WebAuthnCredentialRequestOptions["allowCredentials"];
|
|
69
|
+
}) => Promise<WebAuthnCredentialRequestOptions>;
|
|
70
|
+
verifyCredentialCreation: (username: string, credentials: RegistrationJSON) => Promise<WebAuthnCredential | null>;
|
|
71
|
+
verifyCredentialRequest: (userPublicKeys: {
|
|
72
|
+
id: string;
|
|
73
|
+
publicKey: string;
|
|
74
|
+
algorithm?: NamedAlgo;
|
|
75
|
+
counter?: number;
|
|
76
|
+
transports?: ExtendedAuthenticatorTransport[];
|
|
77
|
+
}[], username: string, credentials: AuthenticationJSON & {
|
|
78
|
+
requestId: string;
|
|
79
|
+
}) => Promise<{
|
|
80
|
+
userHandle: string;
|
|
81
|
+
counter: number;
|
|
22
82
|
} | null>;
|
|
23
83
|
deleteUserWebAuthnCredentials: (username: string) => Promise<number>;
|
|
24
84
|
}>;
|
|
25
|
-
export
|
|
85
|
+
export type UsersWebAuthnModule = Awaited<ReturnType<typeof configureUsersWebAuthnModule>>;
|
|
@@ -1,35 +1,44 @@
|
|
|
1
|
+
import { generateDbObjectId } from '@unchainedshop/mongodb';
|
|
1
2
|
import { createLogger } from '@unchainedshop/logger';
|
|
3
|
+
import pMemoize from 'p-memoize';
|
|
4
|
+
import ExpiryMap from 'expiry-map';
|
|
5
|
+
import { server as webauthnServer } from '@passwordless-id/webauthn';
|
|
2
6
|
import { WebAuthnCredentialsCreationRequestsCollection } from "../db/WebAuthnCredentialsCreationRequestsCollection.js";
|
|
3
7
|
const logger = createLogger('unchained:core-users');
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const fido2LibPackage = await import('fido2-lib');
|
|
8
|
-
Fido2Lib = fido2LibPackage.Fido2Lib;
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
logger.warn(`optional peer npm package 'fido2-lib' not installed, WebAuthn will not work`);
|
|
12
|
-
}
|
|
13
|
-
let setupMDSPromise;
|
|
14
|
-
const setupMDSCollection = async () => {
|
|
8
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
async function fetchMDSEntriesImpl() {
|
|
10
|
+
const cache = new Map();
|
|
15
11
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
const response = await fetch('https://mds.fidoalliance.org/');
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
logger.warn('Failed to fetch FIDO MDS', { status: response.status });
|
|
15
|
+
return cache;
|
|
16
|
+
}
|
|
17
|
+
const jwtBlob = await response.text();
|
|
18
|
+
const parts = jwtBlob.split('.');
|
|
19
|
+
if (parts.length !== 3) {
|
|
20
|
+
logger.warn('Invalid MDS JWT format');
|
|
21
|
+
return cache;
|
|
22
|
+
}
|
|
23
|
+
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
24
|
+
const decoded = Buffer.from(payload, 'base64').toString('utf-8');
|
|
25
|
+
const mdsData = JSON.parse(decoded);
|
|
26
|
+
if (Array.isArray(mdsData.entries)) {
|
|
27
|
+
for (const entry of mdsData.entries) {
|
|
28
|
+
if (entry.aaguid) {
|
|
29
|
+
cache.set(entry.aaguid.toLowerCase(), entry);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
logger.debug(`Loaded ${cache.size} MDS entries`);
|
|
21
34
|
}
|
|
22
|
-
catch (
|
|
23
|
-
logger.error
|
|
24
|
-
return [];
|
|
35
|
+
catch (error) {
|
|
36
|
+
logger.warn('Error fetching MDS', { error: error.message });
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
setupMDSPromise = setupMDSCollection();
|
|
31
|
-
return setupMDSPromise;
|
|
32
|
-
};
|
|
38
|
+
return cache;
|
|
39
|
+
}
|
|
40
|
+
const mdsCache = new ExpiryMap(ONE_DAY_MS);
|
|
41
|
+
const fetchMDSEntries = pMemoize(fetchMDSEntriesImpl, { cache: mdsCache });
|
|
33
42
|
export function toArrayBuffer(buffer) {
|
|
34
43
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
|
35
44
|
}
|
|
@@ -39,128 +48,140 @@ export function buf2hex(buffer) {
|
|
|
39
48
|
.join('');
|
|
40
49
|
}
|
|
41
50
|
export const configureUsersWebAuthnModule = async ({ db }) => {
|
|
51
|
+
const { ROOT_URL = 'http://localhost:4010', EMAIL_WEBSITE_NAME = 'Unchained' } = process.env;
|
|
42
52
|
const WebAuthnCredentialsCreationRequests = await WebAuthnCredentialsCreationRequestsCollection(db);
|
|
43
53
|
const thisDomain = new URL(ROOT_URL).hostname;
|
|
44
|
-
const
|
|
45
|
-
new Fido2Lib({
|
|
46
|
-
rpId: thisDomain,
|
|
47
|
-
rpName: EMAIL_WEBSITE_NAME,
|
|
48
|
-
rpIcon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAARCSURBVHgB7Ve9UxNbFL8fm7yA4ktegHnlMk98dMbOMnR2D6pnGTqtCH+BWFoBpZXQaaelFbGzEztHHF26DKizzijDx+Zef+fu3vXuJgsxztjonQnZOZw993d+5zOM/epHslFO1a/KS/UbuvTnETsKQ/YDh3+XNi72yuVlzVgbL1YhCaOTk2ssDAI24hHDKsrpfxZkufwCj6t0OUAEBMkrlR6wHzjDMVCd872yemeete7gczeKoh2wQTICsyi0DrTWVc55eHqwu8OGPEMCMNSby5hS89H7Nx0Se9NXVvF1J69O7MDwJsKzgfCcmSP9SQhvxcV6S5cnummCHYVH4kJ9DE9NfHx1+HGLxKo0sSOkvIXHSnyz7mjOySufdLmUN3Vp4slZiSoyXk7NrhHVQui1fGzhzTq+QsZ5U07NtowQ3oH2FavDhXjS2389w7W+lrDgI2+2yfaZAEpT/y5LopjztnEkf1F6GXsc38TvWKO9g91NfO3EBGgjpxzooTosCISvXQjAm7zc1Fyvm7IChRHQM+uVcxGBAbstI84bVcqyUE3lAMyVWkrky0UsuGUYRqeni4zQk1cAk15ELxMYy07OqElKqo5ieVWWymsDAbhKGa9Qatag8P5omcQiUEot9nmb1c9cFkm5FJPJWqWp2UYegKkCMf7XHjRaeGwgs+9T1iPTA8ibkM/h5ev4XwVJthEd7D7kF+ozANRI9B9RlpO+lUO/IcZqz0jGPr8PUUFU7k185mwFZUIwBAuGUqWUob8nxCqLQ0H6qbeOnNmQGftOBVHO9QEw+ozZRKLE8y0wxDxFLISIE6n7KsDfjUS8kBoludZbCYBvl8XNaCMPLAOASsde5vYAlFPbeqWV+q/Pq5xR6HRS6zkWzPzIlXdmGKWXFaM/V06zwAGQaVppSJ3yzk7DAqpcb7UQD/q8cuRoxb75dtlxm5Zb3mzALEj7O7I/zWRUBRurHcO7G9Sw5MXJT+rLh+dmRlSqe6iOm1aOC/5nBELr2yTDOw3YO4Z+x3icq7iB01BMXm4j4dbIO+rtVi6nr7xLBg0tIjN20mGGbBPdVs+8J07mvcjzkbnbGf14tNNeYcb4wJVMH358jpo2zSf1lgCM1V4m6CsFXiUG9Iref9txe4kQXgVT9jqXejNZaEKAvFu4D5ikStBjus3bJcPxdjALNE8OduetHep+yIsXGQcZf9wTxyusGwSFS2mCvmaMcn6Lj9d9OV77BGN7iOuCYQFeqcMPT43+peln2IraFH/EdsvuALDTTTthvE0tIRnvsc/x/8/diIq2HnsSFoKEhXUAWM6zYA5VwoDtaLiV7O85XypFy+hVnbRlhCXoo9zZHWkpGWY3/L613D25PZFzeVVxvZqs6ywSYKZ7/ro+2g8TOu6eSBXAGfWIio2z2n871GY8OgN06BcSfitQuZr1CxuQ3Zh/6qkk0/P3GeV8BeBCYUHc/WHIAAAAAElFTkSuQmCC',
|
|
49
|
-
challengeSize: 128,
|
|
50
|
-
attestation: 'none',
|
|
51
|
-
});
|
|
54
|
+
const thisOrigin = new URL(ROOT_URL).origin;
|
|
52
55
|
return {
|
|
53
56
|
findMDSMetadataForAAGUID: async (aaguid) => {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
});
|
|
58
|
-
return foundEntry?.metadataStatement;
|
|
57
|
+
const mdsEntries = await fetchMDSEntries();
|
|
58
|
+
const entry = mdsEntries.get(aaguid.toLowerCase());
|
|
59
|
+
return entry?.metadataStatement || null;
|
|
59
60
|
},
|
|
60
61
|
createCredentialCreationOptions: async (origin, username, extensionOptions) => {
|
|
61
|
-
|
|
62
|
-
return null;
|
|
63
|
-
const registrationOptions = await f2l.attestationOptions(extensionOptions);
|
|
64
|
-
const challenge = btoa(String.fromCharCode(...new Uint8Array(registrationOptions.challenge)));
|
|
62
|
+
const challenge = webauthnServer.randomChallenge();
|
|
65
63
|
const { insertedId } = await WebAuthnCredentialsCreationRequests.insertOne({
|
|
66
|
-
_id:
|
|
64
|
+
_id: generateDbObjectId(),
|
|
67
65
|
challenge,
|
|
68
66
|
origin,
|
|
69
|
-
factor:
|
|
67
|
+
factor: 'either',
|
|
70
68
|
username,
|
|
71
69
|
});
|
|
72
70
|
return {
|
|
73
|
-
...registrationOptions,
|
|
74
71
|
challenge,
|
|
75
72
|
requestId: insertedId,
|
|
73
|
+
rp: {
|
|
74
|
+
id: thisDomain,
|
|
75
|
+
name: EMAIL_WEBSITE_NAME,
|
|
76
|
+
},
|
|
77
|
+
user: {
|
|
78
|
+
id: username,
|
|
79
|
+
name: username,
|
|
80
|
+
displayName: username,
|
|
81
|
+
},
|
|
82
|
+
pubKeyCredParams: [
|
|
83
|
+
{ type: 'public-key', alg: -7 },
|
|
84
|
+
{ type: 'public-key', alg: -257 },
|
|
85
|
+
],
|
|
86
|
+
timeout: extensionOptions?.timeout || 60000,
|
|
87
|
+
attestation: 'none',
|
|
88
|
+
authenticatorSelection: extensionOptions?.authenticatorSelection || {
|
|
89
|
+
userVerification: 'preferred',
|
|
90
|
+
},
|
|
76
91
|
};
|
|
77
92
|
},
|
|
78
93
|
createCredentialRequestOptions: async (origin, username, extensionOptions) => {
|
|
79
|
-
|
|
80
|
-
return null;
|
|
81
|
-
const loginOptions = await f2l.assertionOptions(extensionOptions);
|
|
82
|
-
const challenge = btoa(String.fromCharCode(...new Uint8Array(loginOptions.challenge)));
|
|
94
|
+
const challenge = webauthnServer.randomChallenge();
|
|
83
95
|
const { insertedId } = await WebAuthnCredentialsCreationRequests.insertOne({
|
|
84
|
-
_id:
|
|
96
|
+
_id: generateDbObjectId(),
|
|
85
97
|
challenge,
|
|
86
98
|
origin,
|
|
87
|
-
factor:
|
|
99
|
+
factor: 'either',
|
|
88
100
|
username,
|
|
89
101
|
});
|
|
90
102
|
return {
|
|
91
|
-
...loginOptions,
|
|
92
103
|
challenge,
|
|
93
104
|
requestId: insertedId,
|
|
105
|
+
rpId: thisDomain,
|
|
106
|
+
timeout: extensionOptions?.timeout || 60000,
|
|
107
|
+
userVerification: extensionOptions?.userVerification || 'preferred',
|
|
108
|
+
allowCredentials: extensionOptions?.allowCredentials,
|
|
94
109
|
};
|
|
95
110
|
},
|
|
96
111
|
verifyCredentialCreation: async (username, credentials) => {
|
|
97
|
-
if (!f2l)
|
|
98
|
-
return null;
|
|
99
112
|
const request = await WebAuthnCredentialsCreationRequests.findOne({
|
|
100
113
|
username,
|
|
101
114
|
}, { sort: { _id: -1 } });
|
|
102
|
-
if (!request)
|
|
115
|
+
if (!request) {
|
|
116
|
+
logger.error('WebAuthn: No credential creation request found for username', { username });
|
|
103
117
|
return null;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
118
|
+
}
|
|
119
|
+
const expectedOrigin = request.origin || thisOrigin;
|
|
120
|
+
logger.info('WebAuthn: Verifying credential creation', {
|
|
121
|
+
username,
|
|
122
|
+
expectedOrigin,
|
|
123
|
+
expectedChallenge: request.challenge,
|
|
124
|
+
credentialId: credentials.id,
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
const registrationInfo = await webauthnServer.verifyRegistration(credentials, {
|
|
128
|
+
challenge: request.challenge,
|
|
129
|
+
origin: expectedOrigin,
|
|
130
|
+
});
|
|
131
|
+
logger.info('WebAuthn: Credential creation verified successfully', {
|
|
132
|
+
username,
|
|
133
|
+
credentialId: registrationInfo.credential.id,
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
id: registrationInfo.credential.id,
|
|
137
|
+
publicKey: registrationInfo.credential.publicKey,
|
|
138
|
+
algorithm: registrationInfo.credential.algorithm,
|
|
139
|
+
aaguid: registrationInfo.authenticator.aaguid,
|
|
140
|
+
counter: registrationInfo.authenticator.counter,
|
|
141
|
+
created: new Date(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logger.error('WebAuthn credential creation verification failed', {
|
|
146
|
+
error: error.message,
|
|
147
|
+
username,
|
|
148
|
+
expectedOrigin,
|
|
149
|
+
expectedChallenge: request.challenge,
|
|
150
|
+
});
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
126
153
|
},
|
|
127
154
|
verifyCredentialRequest: async (userPublicKeys, username, credentials) => {
|
|
128
|
-
if (!f2l)
|
|
129
|
-
return null;
|
|
130
155
|
const request = await WebAuthnCredentialsCreationRequests.findOne({
|
|
131
156
|
_id: credentials.requestId,
|
|
132
157
|
}, { sort: { _id: -1 } });
|
|
133
158
|
if (!request)
|
|
134
159
|
return null;
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
const signature = Buffer.from(credentials.response.signature, 'base64');
|
|
138
|
-
const userHandle = Buffer.from(credentials.response.userHandle, 'base64');
|
|
139
|
-
const clientDataJSON = Buffer.from(credentials.response.clientDataJSON, 'base64');
|
|
140
|
-
const { publicKey, counter } = userPublicKeys.find((publicCredentials) => {
|
|
141
|
-
return credentials.id === publicCredentials.id;
|
|
142
|
-
}) || {};
|
|
143
|
-
if (!publicKey)
|
|
160
|
+
const matchingKey = userPublicKeys.find((key) => key.id === credentials.id);
|
|
161
|
+
if (!matchingKey)
|
|
144
162
|
return null;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
userHandle:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
try {
|
|
164
|
+
const credentialKey = {
|
|
165
|
+
id: matchingKey.id,
|
|
166
|
+
publicKey: matchingKey.publicKey,
|
|
167
|
+
algorithm: matchingKey.algorithm || 'ES256',
|
|
168
|
+
transports: matchingKey.transports || [],
|
|
169
|
+
};
|
|
170
|
+
const authenticationInfo = await webauthnServer.verifyAuthentication(credentials, credentialKey, {
|
|
171
|
+
challenge: request.challenge,
|
|
172
|
+
origin: request.origin || thisOrigin,
|
|
173
|
+
userVerified: false,
|
|
174
|
+
counter: matchingKey.counter,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
userHandle: credentials.response.userHandle || username,
|
|
178
|
+
counter: authenticationInfo.counter,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
logger.debug('WebAuthn credential request verification failed', { error: error.message });
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
164
185
|
},
|
|
165
186
|
deleteUserWebAuthnCredentials: async (username) => {
|
|
166
187
|
const { deletedCount } = await WebAuthnCredentialsCreationRequests.deleteMany({
|
package/lib/module/pbkdf2.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeStringEqual } from '@unchainedshop/utils';
|
|
1
2
|
const PBKDF2_ITERATIONS = 300000;
|
|
2
3
|
const PBKDF2_KEY_LENGTH = 256;
|
|
3
4
|
const PBKDF2_SALT_LENGTH = 16;
|
|
@@ -26,5 +27,5 @@ export async function getDerivedKey(salt, password, iterations = PBKDF2_ITERATIO
|
|
|
26
27
|
}
|
|
27
28
|
export async function compare(password, hash, salt) {
|
|
28
29
|
const comparableHash = await getDerivedKey(salt, password);
|
|
29
|
-
return comparableHash
|
|
30
|
+
return timingSafeStringEqual(comparableHash, hash);
|
|
30
31
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function verifyWeb3Signature(nonce: string, signature: `0x${string}`, expectedAddress: string): Promise<boolean>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createLogger } from '@unchainedshop/logger';
|
|
2
|
+
const logger = createLogger('unchained:core-users');
|
|
3
|
+
let secp256k1;
|
|
4
|
+
let keccak_256;
|
|
5
|
+
let bytesToHex;
|
|
6
|
+
let hexToBytes;
|
|
7
|
+
async function loadNoblePackages() {
|
|
8
|
+
try {
|
|
9
|
+
const curves = await import('@noble/curves/secp256k1.js');
|
|
10
|
+
const hashes = await import('@noble/hashes/sha3.js');
|
|
11
|
+
const utils = await import('@noble/hashes/utils.js');
|
|
12
|
+
if (!curves || !hashes || !utils) {
|
|
13
|
+
throw new Error('Missing required @noble packages for Web3 signature verification');
|
|
14
|
+
}
|
|
15
|
+
secp256k1 = curves.secp256k1;
|
|
16
|
+
keccak_256 = hashes.keccak_256;
|
|
17
|
+
bytesToHex = utils.bytesToHex;
|
|
18
|
+
hexToBytes = utils.hexToBytes;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
logger.warn('Failed to load @noble packages for Web3 verification', { error: error.message });
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function fromRPCSig(sig) {
|
|
27
|
+
const bytes = hexToBytes(sig.startsWith('0x') ? sig.slice(2) : sig);
|
|
28
|
+
if (bytes.length !== 65)
|
|
29
|
+
throw new Error('Invalid signature length');
|
|
30
|
+
const r = bytes.slice(0, 32);
|
|
31
|
+
const s = bytes.slice(32, 64);
|
|
32
|
+
let v = BigInt(bytes[64]);
|
|
33
|
+
if (v >= 35n) {
|
|
34
|
+
v = v - 35n - 2n * 1n;
|
|
35
|
+
}
|
|
36
|
+
else if (v >= 27n) {
|
|
37
|
+
v = v - 27n;
|
|
38
|
+
}
|
|
39
|
+
return { v, r, s };
|
|
40
|
+
}
|
|
41
|
+
function hashPersonalMessage(message) {
|
|
42
|
+
const prefix = new TextEncoder().encode('\x19Ethereum Signed Message:\n');
|
|
43
|
+
const lengthBytes = new TextEncoder().encode(message.length.toString());
|
|
44
|
+
const combined = new Uint8Array(prefix.length + lengthBytes.length + message.length);
|
|
45
|
+
combined.set(prefix, 0);
|
|
46
|
+
combined.set(lengthBytes, prefix.length);
|
|
47
|
+
combined.set(message, prefix.length + lengthBytes.length);
|
|
48
|
+
return keccak_256(combined);
|
|
49
|
+
}
|
|
50
|
+
function ecrecover(msgHash, v, r, s) {
|
|
51
|
+
const recovery = Number(v);
|
|
52
|
+
const signature = new Uint8Array(64);
|
|
53
|
+
signature.set(r, 0);
|
|
54
|
+
signature.set(s, 32);
|
|
55
|
+
const publicKey = secp256k1.Signature.fromBytes(signature)
|
|
56
|
+
.addRecoveryBit(recovery)
|
|
57
|
+
.recoverPublicKey(msgHash);
|
|
58
|
+
return publicKey.toBytes(false).slice(1);
|
|
59
|
+
}
|
|
60
|
+
function publicToAddress(publicKey) {
|
|
61
|
+
const hash = keccak_256(publicKey);
|
|
62
|
+
return hash.slice(-20);
|
|
63
|
+
}
|
|
64
|
+
export async function verifyWeb3Signature(nonce, signature, expectedAddress) {
|
|
65
|
+
const packagesLoaded = await loadNoblePackages();
|
|
66
|
+
if (!packagesLoaded) {
|
|
67
|
+
throw new Error('Web3 signature verification is not available. Please install the required @noble packages: @noble/curves, @noble/hashes');
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const messageHash = hashPersonalMessage(hexToBytes(Buffer.from(nonce, 'utf8').toString('hex')));
|
|
71
|
+
const sigParams = fromRPCSig(signature);
|
|
72
|
+
const publicKey = ecrecover(messageHash, sigParams.v, sigParams.r, sigParams.s);
|
|
73
|
+
const sender = publicToAddress(publicKey);
|
|
74
|
+
const recoveredAddr = `0x${bytesToHex(sender)}`;
|
|
75
|
+
return recoveredAddr.toLowerCase() === expectedAddress.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
logger.debug('Web3 signature verification failed', { error: error.message });
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unchainedshop/core-users",
|
|
3
|
-
"
|
|
3
|
+
"description": "User management module for the Unchained Engine with authentication support",
|
|
4
|
+
"version": "4.6.0",
|
|
4
5
|
"main": "lib/users-index.js",
|
|
5
6
|
"types": "lib/users-index.d.ts",
|
|
6
7
|
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
7
9
|
"scripts": {
|
|
8
10
|
"clean": "tsc -b --clean",
|
|
9
11
|
"build": "tsc -b",
|
|
@@ -33,25 +35,33 @@
|
|
|
33
35
|
},
|
|
34
36
|
"homepage": "https://github.com/unchainedshop/unchained#readme",
|
|
35
37
|
"dependencies": {
|
|
38
|
+
"@passwordless-id/webauthn": "^2.3.1",
|
|
39
|
+
"@unchainedshop/events": "^4.6.0",
|
|
40
|
+
"@unchainedshop/file-upload": "^4.6.0",
|
|
41
|
+
"@unchainedshop/logger": "^4.6.0",
|
|
42
|
+
"@unchainedshop/mongodb": "^4.6.0",
|
|
43
|
+
"@unchainedshop/roles": "^4.6.0",
|
|
44
|
+
"@unchainedshop/utils": "^4.6.0",
|
|
36
45
|
"bcryptjs": "^3.0.2",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"@unchainedshop/logger": "^4.4.0",
|
|
40
|
-
"@unchainedshop/mongodb": "^4.4.0",
|
|
41
|
-
"@unchainedshop/roles": "^4.4.0",
|
|
42
|
-
"@unchainedshop/utils": "^4.4.0"
|
|
46
|
+
"expiry-map": "^2.0.0",
|
|
47
|
+
"p-memoize": "^8.0.0"
|
|
43
48
|
},
|
|
44
49
|
"peerDependencies": {
|
|
45
|
-
"
|
|
50
|
+
"@noble/curves": "^2.0.0",
|
|
51
|
+
"@noble/hashes": "^2.0.0"
|
|
46
52
|
},
|
|
47
53
|
"peerDependenciesMeta": {
|
|
48
|
-
"
|
|
54
|
+
"@noble/curves": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@noble/hashes": {
|
|
49
58
|
"optional": true
|
|
50
59
|
}
|
|
51
60
|
},
|
|
52
61
|
"devDependencies": {
|
|
62
|
+
"@noble/curves": "^2.0.0",
|
|
63
|
+
"@noble/hashes": "^2.0.0",
|
|
53
64
|
"@types/node": "^25.0.0",
|
|
54
|
-
"fido2-lib": "^3.5.3",
|
|
55
65
|
"typescript": "^5.8.3"
|
|
56
66
|
}
|
|
57
67
|
}
|