Skip to content

Commit a2912d4

Browse files
committed
feat: add support for sensitive Declarative settings values encryption
Signed-off-by: Andrey Borysenko <[email protected]>
1 parent 68b2a62 commit a2912d4

9 files changed

Lines changed: 139 additions & 6 deletions

File tree

apps/settings/appinfo/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
],
6868
'ocs' => [
6969
['name' => 'DeclarativeSettings#setValue', 'url' => '/settings/api/declarative/value', 'verb' => 'POST', 'root' => ''],
70+
['name' => 'DeclarativeSettings#setSensitiveValue', 'url' => '/settings/api/declarative/value-sensitive', 'verb' => 'POST', 'root' => ''],
7071
['name' => 'DeclarativeSettings#getForms', 'url' => '/settings/api/declarative/forms', 'verb' => 'GET', 'root' => ''],
7172
],
7273
];

apps/settings/lib/Controller/CommonSettingsTrait.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ private function getIndexResponse(string $type, string $section): TemplateRespon
144144
$this->declarativeSettingsManager->loadSchemas();
145145
$declarativeSettings = $this->declarativeSettingsManager->getFormsWithValues($user, $type, $section);
146146

147+
foreach ($declarativeSettings as &$form) {
148+
foreach ($form['fields'] as &$field) {
149+
if (isset($field['sensitive']) && $field['sensitive'] && !empty($field['value'])) {
150+
$field['value'] = 'dummySecret';
151+
}
152+
}
153+
}
154+
147155
if ($type === 'personal') {
148156
$settings = array_values($this->settingsManager->getPersonalSettings($section));
149157
if ($section === 'theming') {

apps/settings/lib/Controller/DeclarativeSettingsController.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OCA\Settings\ResponseDefinitions;
1616
use OCP\AppFramework\Http;
1717
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
18+
use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired;
1819
use OCP\AppFramework\Http\DataResponse;
1920
use OCP\AppFramework\OCS\OCSBadRequestException;
2021
use OCP\AppFramework\OCSController;
@@ -53,6 +54,31 @@ public function __construct(
5354
*/
5455
#[NoAdminRequired]
5556
public function setValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
57+
return $this->saveValue($app, $formId, $fieldId, $value);
58+
}
59+
60+
/**
61+
* Sets a declarative settings value.
62+
* Password confirmation is required for sensitive values.
63+
*
64+
* @param string $app ID of the app
65+
* @param string $formId ID of the form
66+
* @param string $fieldId ID of the field
67+
* @param mixed $value Value to be saved
68+
* @return DataResponse<Http::STATUS_OK, null, array{}>
69+
* @throws NotLoggedInException Not logged in or not an admin user
70+
* @throws NotAdminException Not logged in or not an admin user
71+
* @throws OCSBadRequestException Invalid arguments to save value
72+
*
73+
* 200: Value set successfully
74+
*/
75+
#[NoAdminRequired]
76+
#[PasswordConfirmationRequired]
77+
public function setSensitiveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
78+
return $this->saveValue($app, $formId, $fieldId, $value);
79+
}
80+
81+
private function saveValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
5682
$user = $this->userSession->getUser();
5783
if ($user === null) {
5884
throw new NotLoggedInException();

apps/settings/lib/ResponseDefinitions.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
* default: mixed,
2121
* options?: list<string|array{name: string, value: mixed}>,
2222
* value: string|int|float|bool|list<string>,
23+
* sensitive?: boolean,
2324
* }
2425
*
2526
* @psalm-type SettingsDeclarativeForm = array{

apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
119119
import NcInputField from '@nextcloud/vue/components/NcInputField'
120120
import NcSelect from '@nextcloud/vue/components/NcSelect'
121121
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
122+
import { confirmPassword } from '@nextcloud/password-confirmation'
122123
123124
export default {
124125
name: 'DeclarativeSection',
@@ -202,9 +203,19 @@ export default {
202203
}
203204
},
204205
205-
updateDeclarativeSettingsValue(formField, value = null) {
206+
async updateDeclarativeSettingsValue(formField, value = null) {
206207
try {
207-
return axios.post(generateOcsUrl('settings/api/declarative/value'), {
208+
let url = generateOcsUrl('settings/api/declarative/value')
209+
if (formField?.sensitive) {
210+
url = generateOcsUrl('settings/api/declarative/value-sensitive')
211+
try {
212+
await confirmPassword()
213+
} catch (err) {
214+
showError(t('settings', 'Password confirmation is required'))
215+
return
216+
}
217+
}
218+
return axios.post(url, {
208219
app: this.formApp,
209220
formId: this.form.id.replace(this.formApp + '_', ''), // Remove app prefix to send clean form id
210221
fieldId: formField.id,

apps/settings/src/main-declarative-settings-forms.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface DeclarativeFormField {
2020
options: Array<unknown>|null,
2121
value: unknown,
2222
default: unknown,
23+
sensitive: boolean,
2324
}
2425

2526
interface DeclarativeForm {

apps/testing/lib/Settings/DeclarativeSettingsForm.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,26 @@ public function getSchema(): array {
169169
],
170170
],
171171
],
172+
[
173+
'id' => 'test_sensitive_field',
174+
'title' => 'Sensitive text field',
175+
'description' => 'Set some secure value setting that is stored encrypted',
176+
'type' => DeclarativeSettingsTypes::TEXT,
177+
'label' => 'Sensitive field',
178+
'placeholder' => 'Set secure value',
179+
'default' => '',
180+
'sensitive' => true, // only for TEXT, PASSWORD types
181+
],
182+
[
183+
'id' => 'test_sensitive_field_2',
184+
'title' => 'Sensitive password field',
185+
'description' => 'Set some password setting that is stored encrypted',
186+
'type' => DeclarativeSettingsTypes::PASSWORD,
187+
'label' => 'Sensitive field',
188+
'placeholder' => 'Set secure value',
189+
'default' => '',
190+
'sensitive' => true, // only for TEXT, PASSWORD types
191+
],
172192
],
173193
];
174194
}

lib/private/Settings/DeclarativeManager.php

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use OCP\IConfig;
1616
use OCP\IGroupManager;
1717
use OCP\IUser;
18+
use OCP\Security\ICrypto;
1819
use OCP\Server;
1920
use OCP\Settings\DeclarativeSettingsTypes;
2021
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
@@ -49,6 +50,7 @@ public function __construct(
4950
private IConfig $config,
5051
private IAppConfig $appConfig,
5152
private LoggerInterface $logger,
53+
private ICrypto $crypto,
5254
) {
5355
}
5456

@@ -266,7 +268,7 @@ public function setValue(IUser $user, string $app, string $formId, string $field
266268
$this->eventDispatcher->dispatchTyped(new DeclarativeSettingsSetValueEvent($user, $app, $formId, $fieldId, $value));
267269
break;
268270
case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
269-
$this->saveInternalValue($user, $app, $fieldId, $value);
271+
$this->saveInternalValue($user, $app, $formId, $fieldId, $value);
270272
break;
271273
default:
272274
throw new Exception('Unknown storage type "' . $storageType . '"');
@@ -290,18 +292,54 @@ private function getForm(string $app, string $formId): ?IDeclarativeSettingsForm
290292
private function getInternalValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
291293
$sectionType = $this->getSectionType($app, $fieldId);
292294
$defaultValue = $this->getDefaultValue($app, $formId, $fieldId);
295+
296+
$form = $this->getForm($app, $fieldId);
297+
if ($form === null) {
298+
$field = $this->getSchemaField($app, $formId, $fieldId);
299+
} else {
300+
$field = $this->getSchemaField($app, $formId, $fieldId, $form);
301+
}
302+
$isSensitive = $field !== null && isset($field['sensitive']) && $field['sensitive'];
303+
293304
switch ($sectionType) {
294305
case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
295-
return $this->config->getAppValue($app, $fieldId, $defaultValue);
306+
$value = $this->config->getAppValue($app, $fieldId, $defaultValue);
307+
break;
296308
case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
297-
return $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
309+
$value = $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
310+
break;
298311
default:
299312
throw new Exception('Unknown section type "' . $sectionType . '"');
300313
}
314+
if ($isSensitive && !empty($value)) {
315+
try {
316+
$value = $this->crypto->decrypt($value);
317+
} catch (Exception $e) {
318+
$this->logger->warning(sprintf('Failed to decrypt sensitive value for field "%s" in app "%s": %s', $fieldId, $app, $e->getMessage()));
319+
$value = $defaultValue;
320+
}
321+
}
322+
return $value;
301323
}
302324

303-
private function saveInternalValue(IUser $user, string $app, string $fieldId, mixed $value): void {
325+
private function saveInternalValue(IUser $user, string $app, string $formId, string $fieldId, mixed $value): void {
304326
$sectionType = $this->getSectionType($app, $fieldId);
327+
328+
$form = $this->getForm($app, $formId);
329+
if ($form === null) {
330+
$field = $this->getSchemaField($app, $formId, $fieldId);
331+
} else {
332+
$field = $this->getSchemaField($app, $formId, $fieldId, $form);
333+
}
334+
if ($field !== null && isset($field['sensitive']) && $field['sensitive'] && !empty($value) && $value !== 'dummySecret') {
335+
try {
336+
$value = $this->crypto->encrypt($value);
337+
} catch (Exception $e) {
338+
$this->logger->warning(sprintf('Failed to encrypt sensitive value for field "%s" in app "%s": %s', $fieldId, $app, $e->getMessage()));
339+
throw new Exception('Failed to encrypt sensitive value');
340+
}
341+
}
342+
305343
switch ($sectionType) {
306344
case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
307345
$this->appConfig->setValueString($app, $fieldId, $value);
@@ -314,6 +352,26 @@ private function saveInternalValue(IUser $user, string $app, string $fieldId, mi
314352
}
315353
}
316354

355+
private function getSchemaField(string $app, string $formId, string $fieldId, ?IDeclarativeSettingsForm $form = null): ?array {
356+
if ($form !== null) {
357+
foreach ($form->getSchema()['fields'] as $field) {
358+
if ($field['id'] === $fieldId) {
359+
return $field;
360+
}
361+
}
362+
}
363+
foreach ($this->appSchemas[$app] ?? [] as $schema) {
364+
if ($schema['id'] === $formId) {
365+
foreach ($schema['fields'] as $field) {
366+
if ($field['id'] === $fieldId) {
367+
return $field;
368+
}
369+
}
370+
}
371+
}
372+
return null;
373+
}
374+
317375
private function getDefaultValue(string $app, string $formId, string $fieldId): mixed {
318376
foreach ($this->appSchemas[$app] as $schema) {
319377
if ($schema['id'] === $formId) {
@@ -391,6 +449,12 @@ private function validateSchema(string $appId, array $schema): bool {
391449
]);
392450
return false;
393451
}
452+
if (!in_array($field['type'], [DeclarativeSettingsTypes::TEXT, DeclarativeSettingsTypes::PASSWORD]) && isset($field['sensitive']) && $field['sensitive']) {
453+
$this->logger->warning('Declarative settings: sensitive field type is supported only for TEXT and PASSWORD types', [
454+
'app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId,
455+
]);
456+
return false;
457+
}
394458
if (!$this->validateField($appId, $formId, $field)) {
395459
return false;
396460
}

lib/public/Settings/IDeclarativeSettingsForm.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* label?: string,
2828
* default: mixed,
2929
* options?: list<string|array{name: string, value: mixed}>,
30+
* sensitive?: boolean,
3031
* }
3132
*
3233
* @psalm-type DeclarativeSettingsFormFieldWithValue = DeclarativeSettingsFormField&array{

0 commit comments

Comments
 (0)