Skip to content
This repository was archived by the owner on Jun 25, 2024. It is now read-only.

Commit 077c0f6

Browse files
authored
Merge pull request #102 from silinternational/feature/webauthn
Add support for WebAuthn (leaving U2F support in place)
2 parents 48cc510 + 39f4cad commit 077c0f6

File tree

15 files changed

+367
-22
lines changed

15 files changed

+367
-22
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.idea/
22
vendor/
3+
node_modules/
34
composer.lock
45
nbproject/
56
.vagrant/

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2016 SIL International
3+
Copyright (c) 2021 SIL International
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

Makefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
start:
1+
start: deps
22
docker-compose pull
33
docker-compose up -d
44

5+
copyJsLib:
6+
cp ./node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js ./www/simplewebauthn/browser.js
7+
cp ./node_modules/@simplewebauthn/browser/LICENSE.md ./www/simplewebauthn/LICENSE.md
8+
9+
deps:
10+
docker-compose run --rm node npm install --ignore-scripts
11+
make copyJsLib
12+
13+
depsupdate:
14+
docker-compose run --rm node npm update --ignore-scripts
15+
make copyJsLib
16+
517
errors:
618
docker-compose exec hub cat /var/log/apache2/error.log
719
docker-compose exec idp1 cat /var/log/apache2/error.log

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,28 @@ _Note: This nag only works once since choosing later will simply set the nag da
185185
1. Insert key and press
186186
1. Click **Logout**
187187

188+
#### Key (WebAuthn)
189+
190+
1. Goto [SP 1](http://ssp-sp1.local:8082/module.php/core/authenticate.php?as=hub-discovery)
191+
1. Click **idp4** (third one)
192+
1. Login as a "webauthn" user: `username=`**has_webauthn** `password=`**a**
193+
1. Insert key and press
194+
1. Click **Logout**
195+
188196
#### Multiple options
189197

190198
1. Goto [SP 1](http://ssp-sp1.local:8082/module.php/core/authenticate.php?as=hub-discovery)
191199
1. Click **idp4** (third one)
192200
1. Login as a "multiple option" user: `username=`**has_all** `password=`**a**
193201
1. Click **MORE OPTIONS**
194202

203+
#### Multiple options (legacy, with U2F)
204+
205+
1. Goto [SP 1](http://ssp-sp1.local:8082/module.php/core/authenticate.php?as=hub-discovery)
206+
1. Click **idp4** (third one)
207+
1. Login as a "multiple option" user: `username=`**has_all_legacy** `password=`**a**
208+
1. Click **MORE OPTIONS**
209+
195210
#### Manager rescue
196211

197212
1. Goto [SP 1](http://ssp-sp1.local:8082/module.php/core/authenticate.php?as=hub-discovery)

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"minimum-stability": "stable",
1313
"require": {
1414
"php": ">=7.0",
15+
"ext-json": "*",
1516
"simplesamlphp/composer-module-installer": "^1.1.5",
1617
"simplesamlphp/simplesamlphp": "~1.18.6",
1718
"silinternational/ssp-utilities": "^1.0"

development/idp4/m991231_235959_insert_mfa_test_users.php

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ public function safeUp()
1515
[ 3 ,'a42317a0-9a43-4da0-9921-50f004e011c0','33333' ,'Has' ,'Backup' ,'has_backupcode' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
1616
[ 4 ,'7bab90d3-9f54-4187-804d-7f6400021789','44444' ,'Has' ,'Totp' ,'has_totp' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
1717
[ 5 ,'6b614606-bbe8-4793-b0db-ca862295c661','55555' ,'Has' ,'U2f' ,'has_u2f' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
18-
[ 6 ,'7c695eac-dbca-45d0-b3dc-2df2e1d2294c','77777' ,'Has' ,'All' ,'has_all' ,'has_all@example.org' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
18+
[ 6 ,'7c695eac-dbca-45d0-b3dc-2df2e1d2294c','77777' ,'Has' ,'All' ,'has_all_legacy' ,'has_all_legacy@example.org' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
1919
[ 7 ,'7c695eac-dbca-45d0-b3dc-123jkhf23bql','88888' ,'Review' ,'Needed' ,'needs_review' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::relative('-3 days'), MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
2020
[ 8 ,'7c695eac-dbca-45d0-b3dc-123jkhf23bbq','99999' ,'No' ,'Methods' ,'nag_for_method' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::relative('-1 days'),'[email protected]'],
21+
[ 9 ,'c818d44a-a322-45f4-a1d0-6afc3c2a54e9','66666' ,'Has' ,'WebAuthn' ,'has_webauthn' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
22+
[ 10 ,'9272ae4c-4489-4509-94f3-dae81175e213','77778' ,'Has' ,'All' ,'has_all' ,'[email protected]' ,'yes' ,'no' , MySqlDateTime::now(), MySqlDateTime::now(),'no' , MySqlDateTime::today() , MySqlDateTime::today() , MySqlDateTime::today() ,'[email protected]'],
2123
]);
2224

2325
$this->batchInsert('{{password}}',
@@ -30,30 +32,38 @@ public function safeUp()
3032
[ 6 , 6 ,'$2y$10$rKbAp0M8gewGpQKhD.U6qOSGDlMqKFkxK9tQZ15SZoieqYHYNsD/y', MySqlDateTime::now(), MySqlDateTime::relative('+1 year'), MySqlDateTime::relative('+1 year')],
3133
[ 7 , 7 ,'$2y$10$rKbAp0M8gewGpQKhD.U6qOSGDlMqKFkxK9tQZ15SZoieqYHYNsD/y', MySqlDateTime::now(), MySqlDateTime::relative('+1 year'), MySqlDateTime::relative('+1 year')],
3234
[ 8 , 8 ,'$2y$10$rKbAp0M8gewGpQKhD.U6qOSGDlMqKFkxK9tQZ15SZoieqYHYNsD/y', MySqlDateTime::now(), MySqlDateTime::relative('+1 year'), MySqlDateTime::relative('+1 year')],
35+
[ 9 , 9 ,'$2y$10$rKbAp0M8gewGpQKhD.U6qOSGDlMqKFkxK9tQZ15SZoieqYHYNsD/y', MySqlDateTime::now(), MySqlDateTime::relative('+1 year'), MySqlDateTime::relative('+1 year')],
36+
[ 10 , 10 ,'$2y$10$rKbAp0M8gewGpQKhD.U6qOSGDlMqKFkxK9tQZ15SZoieqYHYNsD/y', MySqlDateTime::now(), MySqlDateTime::relative('+1 year'), MySqlDateTime::relative('+1 year')],
3337
]);
3438

35-
$this->update('{{user}}', ['current_password_id' => 1], 'id=1');
36-
$this->update('{{user}}', ['current_password_id' => 2], 'id=2');
37-
$this->update('{{user}}', ['current_password_id' => 3], 'id=3');
38-
$this->update('{{user}}', ['current_password_id' => 4], 'id=4');
39-
$this->update('{{user}}', ['current_password_id' => 5], 'id=5');
40-
$this->update('{{user}}', ['current_password_id' => 6], 'id=6');
41-
$this->update('{{user}}', ['current_password_id' => 7], 'id=7');
42-
$this->update('{{user}}', ['current_password_id' => 8], 'id=8');
39+
$this->update('{{user}}', ['current_password_id' => 1 ], 'id=1' );
40+
$this->update('{{user}}', ['current_password_id' => 2 ], 'id=2' );
41+
$this->update('{{user}}', ['current_password_id' => 3 ], 'id=3' );
42+
$this->update('{{user}}', ['current_password_id' => 4 ], 'id=4' );
43+
$this->update('{{user}}', ['current_password_id' => 5 ], 'id=5' );
44+
$this->update('{{user}}', ['current_password_id' => 6 ], 'id=6' );
45+
$this->update('{{user}}', ['current_password_id' => 7 ], 'id=7' );
46+
$this->update('{{user}}', ['current_password_id' => 8 ], 'id=8' );
47+
$this->update('{{user}}', ['current_password_id' => 9 ], 'id=9' );
48+
$this->update('{{user}}', ['current_password_id' => 10], 'id=10');
4349

4450
//TODO: unfortunately, a real uuid that's been verified is required for testing at this time ...will discuss decoupling 2-factor config with authentication.
4551
$this->batchInsert('{{mfa}}',
46-
['id','user_id','type' ,'external_uuid' ,'label' ,'verified','created_utc' ],[
47-
[ 1 , 3 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
48-
[ 2 , 4 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
49-
[ 3 , 5 ,'u2f' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key' , 1 , MySqlDateTime::now()],
50-
[ 4 , 6 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
51-
[ 5 , 6 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
52-
[ 6 , 6 ,'u2f' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key' , 1 , MySqlDateTime::now()],
53-
[ 7 , 7 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
54-
[ 8 , 7 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
55-
[ 9 , 7 ,'u2f' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key' , 1 , MySqlDateTime::now()],
56-
[ 10 , 8 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
52+
['id','user_id','type' ,'external_uuid' ,'label' ,'verified','created_utc' ],[
53+
[ 1 , 3 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
54+
[ 2 , 4 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
55+
[ 3 , 5 ,'webauthn' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key (U2F)', 1 , MySqlDateTime::now()],
56+
[ 4 , 6 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
57+
[ 5 , 6 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
58+
[ 6 , 6 ,'webauthn' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key (U2F)', 1 , MySqlDateTime::now()],
59+
[ 7 , 7 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
60+
[ 8 , 7 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
61+
[ 9 , 7 ,'webauthn' ,'6092a08c-b271-4971-996a-6577333a7b6d','Security Key (U2F)', 1 , MySqlDateTime::now()],
62+
[ 10 , 8 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
63+
[ 11 , 9 ,'webauthn' ,'11111111-1111-4111-1111-111111111111','Security Key' , 1 , MySqlDateTime::now()],
64+
[ 12 , 10 ,'backupcode',NULL ,'Printable Codes' , 1 , MySqlDateTime::now()],
65+
[ 13 , 10 ,'totp' ,'38764a89-b904-404e-a195-1ad2bcfabf75','Smartphone App' , 1 , MySqlDateTime::now()], // JVRXKYTMPBEVKXLS
66+
[ 14 , 10 ,'webauthn' ,'11111111-1111-4111-1111-111111111111','Security Key' , 1 , MySqlDateTime::now()],
5767
]);
5868

5969
$this->batchInsert('{{mfa_backupcode}}',
@@ -78,6 +88,11 @@ public function safeUp()
7888
[ 18 , 10 ,'$2y$10$rA5MdrbEcmbCiqtAgPXnYeBCEKc.AnylPArnamyu.x4DS/A0/0/4i', MySqlDateTime::now()], // 77802769
7989
[ 19 , 10 ,'$2y$10$JsiRI/W/FLfZzJLPj8umKeXP.rvsOW4aYQO5mOEOwGkBPpKhKWT2K', MySqlDateTime::now()], // 01970541
8090
[ 20 , 10 ,'$2y$10$NWw0.DPBSm.bjQoSck8xbeqJgENUhE/WazmHmsEtWoxs/UKaIdkUq', MySqlDateTime::now()], // 37771076
91+
[ 21 , 12 ,'$2y$10$j/V6zcotFES8MkVmgRaiMe2E6DV1qjmO8UhUoJQD0/.p6LhZddGn2', MySqlDateTime::now()], // 94923279
92+
[ 22 , 12 ,'$2y$10$If6srqyKGBag/x.nPDBeau9bjNR1RZgxqRVKhdRhJk2PkbOn5rKNS', MySqlDateTime::now()], // 82743523
93+
[ 23 , 12 ,'$2y$10$rA5MdrbEcmbCiqtAgPXnYeBCEKc.AnylPArnamyu.x4DS/A0/0/4i', MySqlDateTime::now()], // 77802769
94+
[ 24 , 12 ,'$2y$10$JsiRI/W/FLfZzJLPj8umKeXP.rvsOW4aYQO5mOEOwGkBPpKhKWT2K', MySqlDateTime::now()], // 01970541
95+
[ 25 , 12 ,'$2y$10$NWw0.DPBSm.bjQoSck8xbeqJgENUhE/WazmHmsEtWoxs/UKaIdkUq', MySqlDateTime::now()], // 37771076
8196
]);
8297

8398
$this->batchInsert('{{method}}',

dictionaries/mfa.definition.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,30 +66,60 @@
6666
"fr": "Clé de sécurité",
6767
"ko": "보안키"
6868
},
69+
"webauthn_header": {
70+
"en": "Security key",
71+
"es": "Clave de seguridad",
72+
"fr": "Clé de sécurité",
73+
"ko": "보안키"
74+
},
6975
"u2f_icon": {
7076
"en": "USB key icon",
7177
"es": "Icono de la llave USB",
7278
"fr": "Icône de clé USB",
7379
"ko": "USB 키 아이콘"
7480
},
81+
"webauthn_icon": {
82+
"en": "USB key icon",
83+
"es": "Icono de la llave USB",
84+
"fr": "Icône de clé USB",
85+
"ko": "USB 키 아이콘"
86+
},
7587
"u2f_instructions": {
7688
"en": "You may now insert your security key and press its button.",
7789
"es": "Ahora puede insertar su clave de seguridad y presionar su botón.",
7890
"fr": "Vous pouvez maintenant insérer votre clé de sécurité et appuyer sur le bouton.",
7991
"ko": "이제 보안 키를 삽입하고 단추를 누를 수 있습니다."
8092
},
93+
"webauthn_instructions": {
94+
"en": "You may now insert your security key and press its button.",
95+
"es": "Ahora puede insertar su clave de seguridad y presionar su botón.",
96+
"fr": "Vous pouvez maintenant insérer votre clé de sécurité et appuyer sur le bouton.",
97+
"ko": "이제 보안 키를 삽입하고 단추를 누를 수 있습니다."
98+
},
8199
"u2f_unsupported": {
82100
"en": "Unsupported in your current browser. Please consider a more secure browser like <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
83101
"es": "No compatible en su navegador actual. Considere un navegador más seguro como <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
84102
"fr": "Non compatible avec votre navigateur actuel. Veuillez considérer un navigateur plus sûr comme <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
85103
"ko": "현재 브라우저에서 지원되지 않습니다. <a href='https://www.google.com/chrome/browser' target='_blank'>Chrome과</a> 같은 보다 안전한 브라우저를 고려하십시오."
86104
},
105+
"webauthn_unsupported": {
106+
"en": "Unsupported in your current browser. Please consider a more secure browser like <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
107+
"es": "No compatible en su navegador actual. Considere un navegador más seguro como <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
108+
"fr": "Non compatible avec votre navigateur actuel. Veuillez considérer un navigateur plus sûr comme <a href='https://www.google.com/chrome/browser' target='_blank'>Google Chrome</a>.",
109+
"ko": "현재 브라우저에서 지원되지 않습니다. <a href='https://www.google.com/chrome/browser' target='_blank'>Chrome과</a> 같은 보다 안전한 브라우저를 고려하십시오."
110+
},
87111
"u2f_error_unknown": {
88112
"en": "Something went wrong with that request, unable to verify at this time.",
89113
"es": "Algo salió mal con esa solicitud, no se pudo verificar en este momento.",
90114
"fr": "Quelque chose s'est mal passé avec cette demande, impossible de vérifier pour le moment.",
91115
"ko": "요청에 문제가 발생하여 지금은 확인할 수 없습니다."
92116
},
117+
"webauthn_error_unknown": {
118+
"en": "Something went wrong with that request, unable to verify at this time.",
119+
"es": "Algo salió mal con esa solicitud, no se pudo verificar en este momento.",
120+
"fr": "Quelque chose s'est mal passé avec cette demande, impossible de vérifier pour le moment.",
121+
"ko": "요청에 문제가 발생하여 지금은 확인할 수 없습니다."
122+
},
93123
"u2f_error_wrong_key": {
94124
"en": "This may not be the correct key for this site.",
95125
"es": "Esta puede no ser la clave correcta para este sitio.",
@@ -102,6 +132,18 @@
102132
"fr": "Cela a pris un peu trop de temps, vérifiez que votre clé est insérée dans le bons sens.",
103133
"ko": "오랜 시간이 경과 되었으니 키가 오른쪽 위로 삽입되었는지 확인 하십시오."
104134
},
135+
"webauthn_error_abort": {
136+
"en": "It looks like you clicked cancel. Would you like us to try again?",
137+
"es": "It looks like you clicked cancel. Would you like us to try again?",
138+
"fr": "Il semble que vous ayez cliqué sur annuler. Souhaitez-vous que nous essayions à nouveau ?",
139+
"ko": "It looks like you clicked cancel. Would you like us to try again?"
140+
},
141+
"webauthn_error_not_allowed": {
142+
"en": "Something about that didn't work. Please ensure that your security key is plugged in and that you touch it within 60 seconds when it blinks.",
143+
"es": "Something about that didn't work. Please ensure that your security key is plugged in and that you touch it within 60 seconds when it blinks.",
144+
"fr": "Quelque chose n'a pas fonctionné avec ça. Veuillez vous assurer que votre clé de sécurité est insérée et que vous la touchez dans les 60 secondes lorsqu'elle clignote.",
145+
"ko": "Something about that didn't work. Please ensure that your security key is plugged in and that you touch it within 60 seconds when it blinks."
146+
},
105147
"manager_icon": {
106148
"en": "Recovery contact icon",
107149
"es": "Icono de contacto de recuperación",
@@ -234,6 +276,12 @@
234276
"fr": "Utiliser plutôt ma clé de sécurité",
235277
"ko": "내 보안키 사용"
236278
},
279+
"use_webauthn": {
280+
"en": "Use my security key instead",
281+
"es": "Use mi clave de seguridad en su lugar",
282+
"fr": "Utiliser plutôt ma clé de sécurité",
283+
"ko": "내 보안키 사용"
284+
},
237285
"use_totp": {
238286
"en": "Use my smartphone app instead",
239287
"es": "Use la aplicación de mi teléfono inteligente en su lugar",

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,11 @@ services:
288288
SECRET_SALT: "xbcCMIHHzsgE8yYC6OIBjsp+ruZYghHn1k5Bv/IGbrg="
289289
IDP_NAME: "sp-2"
290290
IDP_DISPLAY_NAME: "SP 2"
291+
292+
node:
293+
image: node:lts-alpine
294+
volumes:
295+
- ./package.json:/data/package.json
296+
- ./package-lock.json:/data/package-lock.json
297+
- ./node_modules:/data/node_modules
298+
working_dir: /data

local.env.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ MFA_U2F_apiBaseUrl=
66
MFA_U2F_apiKey=
77
MFA_U2F_apiSecret=
88
MFA_U2F_appId=
9+
MFA_WEBAUTHN_apiBaseUrl=
10+
MFA_WEBAUTHN_apiKey=
11+
MFA_WEBAUTHN_apiSecret=
12+
MFA_WEBAUTHN_appId=
13+
MFA_WEBAUTHN_rpDisplayName=
14+
MFA_WEBAUTHN_rpId=
15+
# Array of origins allowed as relying parties (with scheme, without port or path)
16+
RP_ORIGINS=
917

1018
### Optional ENV vars ###
1119
COMPOSER_AUTH=

package-lock.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)