@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 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 type UserQuery = mongodb.Filter<User> & {
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: number;
9
+ _id: string;
10
10
  };
11
11
  export declare const WebAuthnCredentialsCreationRequestsCollection: (db: mongodb.Db) => Promise<mongodb.Collection<Collection>>;
12
12
  export {};
@@ -0,0 +1,2 @@
1
+ import type { MigrationRepository } from '@unchainedshop/mongodb';
2
+ export default function convertUserLocale(repository: MigrationRepository): void;
@@ -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, ...rest }: UserQuery) => mongodb.Filter<User>;
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<any>;
10
- createCredentialCreationOptions: (origin: string, username: string, extensionOptions?: any) => Promise<(Omit<import("fido2-lib").PublicKeyCredentialCreationOptions, "challenge" | "requestId"> & {
11
- challenge: string;
12
- requestId: number;
13
- }) | null>;
14
- createCredentialRequestOptions: (origin: string, username: string, extensionOptions?: any) => Promise<(Omit<import("fido2-lib").PublicKeyCredentialRequestOptions, "challenge" | "requestId"> & {
15
- challenge: string;
16
- requestId: number;
17
- }) | null>;
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
- verifyCredentialRequest: (userPublicKeys: any[], username: string, credentials: any) => Promise<{
26
- userHandle: any;
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, ...rest }) => {
32
- const selector = { ...rest };
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: { $exists: false } }, { limit: 1 });
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: { $exists: false },
771
+ deleted: null,
640
772
  }));
641
773
  return tags.filter(Boolean).toSorted();
642
774
  },
@@ -1,25 +1,85 @@
1
- import type { ModuleInput } from '@unchainedshop/mongodb';
2
- import type { PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions } from 'fido2-lib';
3
- type SerializedOptions<T> = Omit<T, 'challenge' | 'requestId'> & {
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: number;
6
- };
7
- export declare function toArrayBuffer(buffer: any): any;
8
- export declare function buf2hex(buffer: any): string;
9
- export declare const configureUsersWebAuthnModule: ({ db }: ModuleInput<any>) => Promise<{
10
- findMDSMetadataForAAGUID: (aaguid: string) => Promise<any>;
11
- createCredentialCreationOptions: (origin: string, username: string, extensionOptions?: any) => Promise<SerializedOptions<PublicKeyCredentialCreationOptions> | null>;
12
- createCredentialRequestOptions: (origin: string, username: string, extensionOptions?: any) => Promise<SerializedOptions<PublicKeyCredentialRequestOptions> | null>;
13
- verifyCredentialCreation: (username: string, credentials: any) => Promise<{
14
- publicKey: any;
15
- counter: any;
16
- id: any;
17
- aaguid: string;
18
- created: Date;
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
- verifyCredentialRequest: (userPublicKeys: any[], username: string, credentials: any) => Promise<{
21
- userHandle: any;
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 { ROOT_URL = 'http://localhost:4010', EMAIL_WEBSITE_NAME = 'Unchained' } = process.env;
5
- let Fido2Lib;
6
- try {
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 tocResult = await fetch('https://mds.fidoalliance.org');
17
- const tocBase64 = await tocResult.text();
18
- const mc = Fido2Lib.createMdsCollection('FIDO MDS v3');
19
- const tocObj = await mc.addToc(tocBase64);
20
- return tocObj.entries;
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 (e) {
23
- logger.error(e);
24
- return [];
35
+ catch (error) {
36
+ logger.warn('Error fetching MDS', { error: error.message });
25
37
  }
26
- };
27
- const fetchMDS = async () => {
28
- if (setupMDSPromise)
29
- return setupMDSPromise;
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 f2l = Fido2Lib &&
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 mdsCollection = await fetchMDS();
55
- const foundEntry = mdsCollection.find((entry) => {
56
- return entry.aaguid === aaguid;
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
- if (!f2l)
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: new Date().getTime(),
64
+ _id: generateDbObjectId(),
67
65
  challenge,
68
66
  origin,
69
- factor: registrationOptions.factor || 'either',
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
- if (!f2l)
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: new Date().getTime(),
96
+ _id: generateDbObjectId(),
85
97
  challenge,
86
98
  origin,
87
- factor: loginOptions.factor || 'either',
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
- const attestationExpectations = {
105
- challenge: request.challenge,
106
- origin: request.origin,
107
- factor: request.factor,
108
- };
109
- const id = Buffer.from(credentials.id, 'base64');
110
- const attestationObject = Buffer.from(credentials.response.attestationObject, 'base64');
111
- const clientDataJSON = Buffer.from(credentials.response.clientDataJSON, 'base64');
112
- const attestationResponse = {
113
- id: toArrayBuffer(id),
114
- response: {
115
- attestationObject: toArrayBuffer(attestationObject),
116
- clientDataJSON: toArrayBuffer(clientDataJSON),
117
- },
118
- };
119
- const registrationOptions = await f2l.attestationResult(attestationResponse, attestationExpectations);
120
- const publicKey = registrationOptions?.authnrData?.get('credentialPublicKeyPem');
121
- const aaguidArrayBuffer = registrationOptions?.authnrData?.get('aaguid');
122
- const counter = registrationOptions?.authnrData?.get('counter');
123
- const aaguidConcatenated = buf2hex(aaguidArrayBuffer);
124
- const aaguid = `${aaguidConcatenated.slice(0, 8)}-${aaguidConcatenated.slice(8, 12)}-${aaguidConcatenated.slice(12, 16)}-${aaguidConcatenated.slice(16, 20)}-${aaguidConcatenated.slice(20)}`;
125
- return { publicKey, counter, id: credentials.id, aaguid, created: new Date() };
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 id = Buffer.from(credentials.id, 'base64');
136
- const authenticatorData = Buffer.from(credentials.response.authenticatorData, 'base64');
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
- const assertionExpectations = {
146
- challenge: request.challenge,
147
- origin: request.origin,
148
- factor: request.factor,
149
- prevCounter: counter,
150
- publicKey,
151
- userHandle: toArrayBuffer(Buffer.from(username)),
152
- };
153
- const assertionResponse = {
154
- id: toArrayBuffer(id),
155
- response: {
156
- authenticatorData: toArrayBuffer(authenticatorData),
157
- clientDataJSON: toArrayBuffer(clientDataJSON),
158
- signature: toArrayBuffer(signature),
159
- userHandle: toArrayBuffer(userHandle),
160
- },
161
- };
162
- const loginResult = await f2l.assertionResult(assertionResponse, assertionExpectations);
163
- return { userHandle: loginResult?.authnrData?.get('userHandle') };
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({
@@ -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 === hash;
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
- "version": "4.4.0",
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
- "@unchainedshop/events": "^4.4.0",
38
- "@unchainedshop/file-upload": "^4.4.0",
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
- "fido2-lib": ">= 3.5 < 4"
50
+ "@noble/curves": "^2.0.0",
51
+ "@noble/hashes": "^2.0.0"
46
52
  },
47
53
  "peerDependenciesMeta": {
48
- "fido2-lib": {
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
  }