@stamhoofd/backend 2.138.0 → 2.138.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -17
- package/src/helpers/WebauthnHelper.test.ts +144 -0
- package/src/helpers/WebauthnHelper.ts +36 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.138.
|
|
3
|
+
"version": "2.138.1",
|
|
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.
|
|
69
|
-
"@stamhoofd/backend-i18n": "2.138.
|
|
70
|
-
"@stamhoofd/backend-middleware": "2.138.
|
|
71
|
-
"@stamhoofd/crons": "2.138.
|
|
72
|
-
"@stamhoofd/email": "2.138.
|
|
73
|
-
"@stamhoofd/excel-writer": "2.138.
|
|
74
|
-
"@stamhoofd/logging": "2.138.
|
|
75
|
-
"@stamhoofd/models": "2.138.
|
|
76
|
-
"@stamhoofd/object-differ": "2.138.
|
|
77
|
-
"@stamhoofd/queues": "2.138.
|
|
78
|
-
"@stamhoofd/sql": "2.138.
|
|
79
|
-
"@stamhoofd/structures": "2.138.
|
|
80
|
-
"@stamhoofd/types": "2.138.
|
|
81
|
-
"@stamhoofd/utility": "2.138.
|
|
68
|
+
"@stamhoofd/backend-env": "2.138.1",
|
|
69
|
+
"@stamhoofd/backend-i18n": "2.138.1",
|
|
70
|
+
"@stamhoofd/backend-middleware": "2.138.1",
|
|
71
|
+
"@stamhoofd/crons": "2.138.1",
|
|
72
|
+
"@stamhoofd/email": "2.138.1",
|
|
73
|
+
"@stamhoofd/excel-writer": "2.138.1",
|
|
74
|
+
"@stamhoofd/logging": "2.138.1",
|
|
75
|
+
"@stamhoofd/models": "2.138.1",
|
|
76
|
+
"@stamhoofd/object-differ": "2.138.1",
|
|
77
|
+
"@stamhoofd/queues": "2.138.1",
|
|
78
|
+
"@stamhoofd/sql": "2.138.1",
|
|
79
|
+
"@stamhoofd/structures": "2.138.1",
|
|
80
|
+
"@stamhoofd/types": "2.138.1",
|
|
81
|
+
"@stamhoofd/utility": "2.138.1",
|
|
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.
|
|
105
|
+
"@stamhoofd/test-utils": "2.138.1",
|
|
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": "
|
|
120
|
+
"gitHead": "ea8e0dcbc22e86969883fa09e920931896110781"
|
|
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)
|
|
56
|
-
* `capacitor://<host
|
|
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
|
|
61
|
-
* the operating system verifies before it releases a credential to any
|
|
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
|
|
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
|
|
218
|
+
expectedOrigin,
|
|
191
219
|
expectedRPID: rpId,
|
|
192
220
|
credential: {
|
|
193
221
|
id: storedCredential.credentialId,
|