@stamhoofd/backend 2.138.0 → 2.138.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.138.0",
3
+ "version": "2.138.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -65,20 +65,20 @@
65
65
  "@simonbackx/simple-errors": "1.5.0",
66
66
  "@simonbackx/simple-logging": "1.0.1",
67
67
  "@simplewebauthn/server": "13.3.2",
68
- "@stamhoofd/backend-env": "2.138.0",
69
- "@stamhoofd/backend-i18n": "2.138.0",
70
- "@stamhoofd/backend-middleware": "2.138.0",
71
- "@stamhoofd/crons": "2.138.0",
72
- "@stamhoofd/email": "2.138.0",
73
- "@stamhoofd/excel-writer": "2.138.0",
74
- "@stamhoofd/logging": "2.138.0",
75
- "@stamhoofd/models": "2.138.0",
76
- "@stamhoofd/object-differ": "2.138.0",
77
- "@stamhoofd/queues": "2.138.0",
78
- "@stamhoofd/sql": "2.138.0",
79
- "@stamhoofd/structures": "2.138.0",
80
- "@stamhoofd/types": "2.138.0",
81
- "@stamhoofd/utility": "2.138.0",
68
+ "@stamhoofd/backend-env": "2.138.2",
69
+ "@stamhoofd/backend-i18n": "2.138.2",
70
+ "@stamhoofd/backend-middleware": "2.138.2",
71
+ "@stamhoofd/crons": "2.138.2",
72
+ "@stamhoofd/email": "2.138.2",
73
+ "@stamhoofd/excel-writer": "2.138.2",
74
+ "@stamhoofd/logging": "2.138.2",
75
+ "@stamhoofd/models": "2.138.2",
76
+ "@stamhoofd/object-differ": "2.138.2",
77
+ "@stamhoofd/queues": "2.138.2",
78
+ "@stamhoofd/sql": "2.138.2",
79
+ "@stamhoofd/structures": "2.138.2",
80
+ "@stamhoofd/types": "2.138.2",
81
+ "@stamhoofd/utility": "2.138.2",
82
82
  "archiver": "7.0.1",
83
83
  "argon2": "0.44.0",
84
84
  "axios": "1.18.1",
@@ -102,7 +102,7 @@
102
102
  "uuid": "14.0.1"
103
103
  },
104
104
  "devDependencies": {
105
- "@stamhoofd/test-utils": "2.138.0",
105
+ "@stamhoofd/test-utils": "2.138.2",
106
106
  "@types/cookie": "0.6.0",
107
107
  "@types/luxon": "3.7.2",
108
108
  "@types/mailparser": "3.4.6",
@@ -117,5 +117,5 @@
117
117
  "publishConfig": {
118
118
  "access": "public"
119
119
  },
120
- "gitHead": "781877acb024516e384b4119af7e86f847a9d237"
120
+ "gitHead": "32d393fa2d3dacbd74aff03e62cc9244d2d7535f"
121
121
  }
@@ -0,0 +1,144 @@
1
+ import { isoCBOR } from '@simplewebauthn/server/helpers';
2
+ import { WebauthnCredential } from '@stamhoofd/models';
3
+ import { WebauthnAssertionResponseData, WebauthnAuthenticationCredential } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import crypto from 'crypto';
6
+
7
+ import { WebauthnHelper } from './WebauthnHelper.js';
8
+
9
+ // The fingerprint of one of the Android release certificates, in the notation of
10
+ // assetlinks.json, and the origin Android derives from it.
11
+ const FINGERPRINT = 'A0:7B:07:40:BD:36:D6:07:29:C5:E4:5C:06:68:C6:CE:4B:B0:F6:F8:CD:B3:51:FC:1E:CF:06:78:AF:7C:2C:75';
12
+ const FINGERPRINT_ORIGIN = 'android:apk-key-hash:oHsHQL021gcpxeRcBmjGzkuw9vjNs1H8Hs8GeK98LHU';
13
+
14
+ /**
15
+ * A passkey as an authenticator holds it: an ES256 key pair, plus the COSE encoding of the
16
+ * public key that we store on the credential.
17
+ */
18
+ function createKeyPair() {
19
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
20
+ const jwk = publicKey.export({ format: 'jwk' });
21
+
22
+ const cose = isoCBOR.encode(new Map<number, number | Uint8Array>([
23
+ [1, 2], // kty: EC2
24
+ [3, -7], // alg: ES256
25
+ [-1, 1], // crv: P-256
26
+ [-2, new Uint8Array(Buffer.from(jwk.x!, 'base64url'))],
27
+ [-3, new Uint8Array(Buffer.from(jwk.y!, 'base64url'))],
28
+ ]));
29
+
30
+ return { privateKey, cosePublicKey: Buffer.from(cose).toString('base64url') };
31
+ }
32
+
33
+ /**
34
+ * Build and sign the assertion an authenticator returns when it releases a passkey, so the
35
+ * whole verification path runs (origin, RP ID hash, challenge and signature).
36
+ */
37
+ function signAssertion({ privateKey, rpId, origin, challenge }: { privateKey: crypto.KeyObject; rpId: string; origin: string; challenge: string }): WebauthnAuthenticationCredential {
38
+ const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge, origin, crossOrigin: false }));
39
+
40
+ // rpIdHash (32) + flags (user present + user verified) + signature counter
41
+ const authenticatorData = Buffer.concat([
42
+ crypto.createHash('sha256').update(rpId).digest(),
43
+ Buffer.from([0x05]),
44
+ Buffer.from([0x00, 0x00, 0x00, 0x01]),
45
+ ]);
46
+
47
+ const signature = crypto.sign('sha256', Buffer.concat([
48
+ authenticatorData,
49
+ crypto.createHash('sha256').update(clientDataJSON).digest(),
50
+ ]), privateKey);
51
+
52
+ return WebauthnAuthenticationCredential.create({
53
+ id: 'test-credential',
54
+ rawId: 'test-credential',
55
+ response: WebauthnAssertionResponseData.create({
56
+ clientDataJSON: clientDataJSON.toString('base64url'),
57
+ authenticatorData: authenticatorData.toString('base64url'),
58
+ signature: signature.toString('base64url'),
59
+ }),
60
+ });
61
+ }
62
+
63
+ function createStoredCredential(cosePublicKey: string): WebauthnCredential {
64
+ const credential = new WebauthnCredential();
65
+ credential.userId = 'test-user';
66
+ credential.credentialId = 'test-credential';
67
+ credential.publicKey = cosePublicKey;
68
+ credential.counter = 0;
69
+ credential.rpId = WebauthnHelper.getRpID();
70
+ return credential;
71
+ }
72
+
73
+ describe('WebauthnHelper', () => {
74
+ describe('expected origins', () => {
75
+ test('the web apps and the iOS app are accepted', () => {
76
+ const rpId = WebauthnHelper.getRpID();
77
+ const origins = WebauthnHelper.getExpectedOrigins(rpId);
78
+
79
+ expect(origins).toContain('https://' + rpId);
80
+ expect(origins).toContain('capacitor://' + rpId);
81
+ });
82
+
83
+ test('a configured Android certificate is accepted as an origin', () => {
84
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
85
+
86
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toContain(FINGERPRINT_ORIGIN);
87
+ });
88
+
89
+ test('a fingerprint without separators is accepted too', () => {
90
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT.replace(/:/g, '').toLowerCase()]);
91
+
92
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toContain(FINGERPRINT_ORIGIN);
93
+ });
94
+
95
+ test('no Android origins are accepted when none are configured', () => {
96
+ expect(STAMHOOFD.ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS).toBeUndefined();
97
+
98
+ expect(WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID()).filter(o => o.startsWith('android:'))).toHaveLength(0);
99
+ });
100
+
101
+ test('a fingerprint that is not a SHA-256 hash is rejected', () => {
102
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', ['A0:7B:07']);
103
+
104
+ expect(() => WebauthnHelper.getExpectedOrigins(WebauthnHelper.getRpID())).toThrow('Invalid SHA-256 certificate fingerprint');
105
+ });
106
+ });
107
+
108
+ describe('verifying an assertion', () => {
109
+ test('a passkey used inside the Android app is accepted', async () => {
110
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
111
+
112
+ const { privateKey, cosePublicKey } = createKeyPair();
113
+ const stored = createStoredCredential(cosePublicKey);
114
+ const challenge = crypto.randomBytes(32).toString('base64url');
115
+
116
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: FINGERPRINT_ORIGIN, challenge });
117
+
118
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBe(1);
119
+ });
120
+
121
+ test('a passkey used inside an app we did not sign is refused', async () => {
122
+ TestUtils.setEnvironment('ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS', [FINGERPRINT]);
123
+
124
+ const { privateKey, cosePublicKey } = createKeyPair();
125
+ const stored = createStoredCredential(cosePublicKey);
126
+ const challenge = crypto.randomBytes(32).toString('base64url');
127
+
128
+ const otherApp = 'android:apk-key-hash:' + crypto.randomBytes(32).toString('base64url');
129
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: otherApp, challenge });
130
+
131
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBeNull();
132
+ });
133
+
134
+ test('a passkey used in the browser stays accepted', async () => {
135
+ const { privateKey, cosePublicKey } = createKeyPair();
136
+ const stored = createStoredCredential(cosePublicKey);
137
+ const challenge = crypto.randomBytes(32).toString('base64url');
138
+
139
+ const assertion = signAssertion({ privateKey, rpId: stored.rpId!, origin: 'https://' + stored.rpId, challenge });
140
+
141
+ expect(await WebauthnHelper.verifyAuthentication(assertion, challenge, stored)).toBe(1);
142
+ });
143
+ });
144
+ });
@@ -48,20 +48,46 @@ function getCredentialRpID(credential: WebauthnCredential): string {
48
48
  return credential.rpId ?? getRpID();
49
49
  }
50
50
 
51
+ /**
52
+ * Convert a SHA-256 certificate fingerprint to the base64url encoding Android uses in its
53
+ * origin. Accepts the colon separated hexadecimal notation of assetlinks.json, with or
54
+ * without separators.
55
+ */
56
+ function fingerprintToBase64URL(fingerprint: string): string {
57
+ const hex = fingerprint.replace(/[\s:-]/g, '');
58
+ if (!/^[0-9a-f]{64}$/i.test(hex)) {
59
+ throw new Error('Invalid SHA-256 certificate fingerprint in ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS: ' + fingerprint);
60
+ }
61
+ return Buffer.from(hex, 'hex').toString('base64url');
62
+ }
63
+
64
+ /**
65
+ * The origins the Android app presents a passkey from.
66
+ *
67
+ * Android reports the app that made the call instead of the address of the web view:
68
+ * `android:apk-key-hash:<base64url sha-256 of the signing certificate>`. The certificates
69
+ * are configured through ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS and are the same ones as
70
+ * in the assetlinks.json of the RP ID, which is what makes Android hand our passkeys to
71
+ * the app in the first place.
72
+ */
73
+ function getAndroidOrigins(): string[] {
74
+ return (STAMHOOFD.ANDROID_PASSKEY_SHA256_CERT_FINGERPRINTS ?? []).map(fingerprint => 'android:apk-key-hash:' + fingerprintToBase64URL(fingerprint));
75
+ }
76
+
51
77
  /**
52
78
  * The origins a passkey for `rpId` may be presented from.
53
79
  *
54
80
  * The web apps run on https. The Capacitor app serves its web view from the same host but
55
- * a custom scheme (see frontend/app/mobile/capacitor.config.json): iOS reports
56
- * `capacitor://<host>`, while Android is configured with the https scheme and so already
57
- * matches the first entry.
81
+ * a custom scheme (see frontend/app/mobile/capacitor.config.json), which iOS reports as
82
+ * `capacitor://<host>`; Android reports its own signature instead, see getAndroidOrigins.
58
83
  *
59
84
  * Widening the origin does not widen who can use these passkeys: the app only gets to see
60
- * them because it is listed in the associated domains (webcredentials) of the RP ID, which
61
- * the operating system verifies before it releases a credential to any app.
85
+ * them because it is listed in the associated domains (webcredentials / assetlinks.json)
86
+ * of the RP ID, which the operating system verifies before it releases a credential to any
87
+ * app.
62
88
  */
63
89
  function getExpectedOrigins(rpId: string): string[] {
64
- return ['https://' + rpId, 'capacitor://' + rpId];
90
+ return ['https://' + rpId, 'capacitor://' + rpId, ...getAndroidOrigins()];
65
91
  }
66
92
 
67
93
  export const WebauthnHelper = {
@@ -107,13 +133,14 @@ export const WebauthnHelper = {
107
133
  };
108
134
 
109
135
  const rpId = getRpID();
136
+ const expectedOrigin = getExpectedOrigins(rpId);
110
137
 
111
138
  let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
112
139
  try {
113
140
  verification = await verifyRegistrationResponse({
114
141
  response,
115
142
  expectedChallenge,
116
- expectedOrigin: getExpectedOrigins(rpId),
143
+ expectedOrigin,
117
144
  expectedRPID: rpId,
118
145
  requireUserVerification: false,
119
146
  });
@@ -181,13 +208,14 @@ export const WebauthnHelper = {
181
208
 
182
209
  // Verify against the RP ID this credential was created for, not the current one.
183
210
  const rpId = getCredentialRpID(storedCredential);
211
+ const expectedOrigin = getExpectedOrigins(rpId);
184
212
 
185
213
  let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
186
214
  try {
187
215
  verification = await verifyAuthenticationResponse({
188
216
  response,
189
217
  expectedChallenge,
190
- expectedOrigin: getExpectedOrigins(rpId),
218
+ expectedOrigin,
191
219
  expectedRPID: rpId,
192
220
  credential: {
193
221
  id: storedCredential.credentialId,