Skip to content

Commit d9a61b1

Browse files
committed
feat(smime): allow selection of untrusted certs
Signed-off-by: Richard Steinmetz <[email protected]>
1 parent 5c7a879 commit d9a61b1

File tree

5 files changed

+105
-53
lines changed

5 files changed

+105
-53
lines changed

lib/Model/EnrichedSmimeCertificate.php

+1-21
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,14 @@
3232
class EnrichedSmimeCertificate implements JsonSerializable {
3333
private SmimeCertificate $certificate;
3434
private SmimeCertificateInfo $info;
35-
private SmimeCertificatePurposes $purposes;
3635

3736
/**
3837
* @param SmimeCertificate $certificate
3938
* @param SmimeCertificateInfo $info
40-
* @param SmimeCertificatePurposes $purposes
4139
*/
42-
public function __construct(SmimeCertificate $certificate,
43-
SmimeCertificateInfo $info,
44-
SmimeCertificatePurposes $purposes) {
40+
public function __construct(SmimeCertificate $certificate, SmimeCertificateInfo $info) {
4541
$this->certificate = $certificate;
4642
$this->info = $info;
47-
$this->purposes = $purposes;
4843
}
4944

5045
/**
@@ -75,25 +70,10 @@ public function setInfo(SmimeCertificateInfo $info): void {
7570
$this->info = $info;
7671
}
7772

78-
/**
79-
* @return SmimeCertificatePurposes
80-
*/
81-
public function getPurposes(): SmimeCertificatePurposes {
82-
return $this->purposes;
83-
}
84-
85-
/**
86-
* @param SmimeCertificatePurposes $purposes
87-
*/
88-
public function setPurposes(SmimeCertificatePurposes $purposes): void {
89-
$this->purposes = $purposes;
90-
}
91-
9273
#[ReturnTypeWillChange]
9374
public function jsonSerialize() {
9475
$json = $this->certificate->jsonSerialize();
9576
$json['info'] = $this->info->jsonSerialize();
96-
$json['purposes'] = $this->purposes->jsonSerialize();
9777
return $json;
9878
}
9979
}

lib/Model/SmimeCertificateInfo.php

+25-1
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@ class SmimeCertificateInfo implements JsonSerializable {
3232
private ?string $commonName;
3333
private ?string $emailAddress;
3434
private int $notAfter;
35+
private SmimeCertificatePurposes $purposes;
36+
private bool $isChainVerified;
3537

3638
public function __construct(?string $commonName,
3739
?string $emailAddress,
38-
int $notAfter) {
40+
int $notAfter,
41+
SmimeCertificatePurposes $purposes,
42+
bool $isChainVerified) {
3943
$this->commonName = $commonName;
4044
$this->emailAddress = $emailAddress;
4145
$this->notAfter = $notAfter;
46+
$this->purposes = $purposes;
47+
$this->isChainVerified = $isChainVerified;
4248
}
4349

4450
/**
@@ -83,12 +89,30 @@ public function setNotAfter(int $notAfter): void {
8389
$this->notAfter = $notAfter;
8490
}
8591

92+
public function getPurposes(): SmimeCertificatePurposes {
93+
return $this->purposes;
94+
}
95+
96+
public function setPurposes(SmimeCertificatePurposes $purposes): void {
97+
$this->purposes = $purposes;
98+
}
99+
100+
public function isChainVerified(): bool {
101+
return $this->isChainVerified;
102+
}
103+
104+
public function setIsChainVerified(bool $isChainVerified): void {
105+
$this->isChainVerified = $isChainVerified;
106+
}
107+
86108
#[ReturnTypeWillChange]
87109
public function jsonSerialize() {
88110
return [
89111
'commonName' => $this->commonName,
90112
'emailAddress' => $this->emailAddress,
91113
'notAfter' => $this->notAfter,
114+
'purposes' => $this->purposes->jsonSerialize(),
115+
'isChainVerified' => $this->isChainVerified,
92116
];
93117
}
94118
}

lib/Service/SmimeService.php

+13-15
Original file line numberDiff line numberDiff line change
@@ -172,24 +172,23 @@ public function parseCertificate(string $certificate): SmimeCertificateInfo {
172172
throw new SmimeCertificateParserException('Certificate does not contain an email address');
173173
}
174174

175+
$purposes = new SmimeCertificatePurposes(false, false);
176+
foreach ($certificateData['purposes'] as $purpose) {
177+
[$state, $_, $name] = $purpose;
178+
if ($name === 'smimesign') {
179+
$purposes->setSign((bool)$state);
180+
} elseif ($name === 'smimeencrypt') {
181+
$purposes->setEncrypt((bool)$state);
182+
}
183+
}
184+
185+
$caBundle = [$this->certificateManager->getAbsoluteBundlePath()];
175186
return new SmimeCertificateInfo(
176187
$certificateData['subject']['CN'] ?? null,
177188
$certificateData['subject']['emailAddress'] ?? $certificateData['subject']['CN'],
178189
$certificateData['validTo_time_t'],
179-
);
180-
}
181-
182-
/**
183-
* Get S/MIME related certificate purposes of the given certificate.
184-
*
185-
* @param string $certificate X509 certificate encoded as PEM
186-
* @return SmimeCertificatePurposes
187-
*/
188-
public function getCertificatePurposes(string $certificate): SmimeCertificatePurposes {
189-
$caBundle = [$this->certificateManager->getAbsoluteBundlePath()];
190-
return new SmimeCertificatePurposes(
191-
openssl_x509_checkpurpose($certificate, X509_PURPOSE_SMIME_SIGN, $caBundle),
192-
openssl_x509_checkpurpose($certificate, X509_PURPOSE_SMIME_ENCRYPT, $caBundle),
190+
$purposes,
191+
openssl_x509_checkpurpose($certificate, X509_PURPOSE_ANY, $caBundle) === true,
193192
);
194193
}
195194

@@ -216,7 +215,6 @@ public function enrichCertificate(SmimeCertificate $certificate): EnrichedSmimeC
216215
return new EnrichedSmimeCertificate(
217216
$certificate,
218217
$this->parseCertificate($decryptedCertificate),
219-
$this->getCertificatePurposes($decryptedCertificate),
220218
);
221219
}
222220

src/components/CertificateSettings.vue

+47-16
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
-->
2222

2323
<template>
24-
<div>
24+
<div class="certificate-settings">
2525
<NcSelect v-model="alias"
26+
class="certificate-settings__alias"
2627
:options="aliases"
2728
:searchable="false"
2829
:placeholder="t('mail', 'Select an alias')"
@@ -31,31 +32,43 @@
3132
@input="savedCertificate = null" />
3233
<NcSelect v-if="alias !== null"
3334
v-model="savedCertificate"
35+
class="certificate-settings__certificate"
3436
:options="smimeCertOptions"
3537
:aria-label-combobox="t('mail', 'Select certificates')"
3638
:searchable="false" />
37-
<ButtonVue type="primary"
39+
<NcButton type="primary"
40+
class="certificate-settings__submit"
3841
:disabled="certificate === null"
3942
:aria-label="t('mail', 'Update Certificate')"
4043
@click="updateSmimeCertificate">
4144
{{ t('mail', 'Update Certificate') }}
42-
</ButtonVue>
45+
</NcButton>
46+
<NcNoteCard v-if="alias && !savedCertificate.isChainVerified"
47+
type="warning">
48+
<p>{{ t('mail', 'The selected certificate is not trusted by the server. Recipients might not be able to verify your signature.') }}</p>
49+
</NcNoteCard>
4350
</div>
4451
</template>
4552

4653
<script>
47-
import { NcSelect, NcButton as ButtonVue } from '@nextcloud/vue'
54+
import { NcSelect, NcButton, NcNoteCard } from '@nextcloud/vue'
4855
import { compareSmimeCertificates } from '../util/smime.js'
4956
import { mapGetters } from 'vuex'
5057
import { showError, showSuccess } from '@nextcloud/dialogs'
5158
import Logger from '../logger.js'
5259
import moment from '@nextcloud/moment'
5360

61+
const noCertificateOption = () => ({
62+
label: t('mail', 'No certificate'),
63+
isChainVerified: true,
64+
})
65+
5466
export default {
5567
name: 'CertificateSettings',
5668
components: {
5769
NcSelect,
58-
ButtonVue,
70+
NcButton,
71+
NcNoteCard,
5972
},
6073
props: {
6174
account: {
@@ -79,7 +92,7 @@ export default {
7992
return this.certificate
8093
}
8194
const saved = this.smimeCertOptions.find(certificate => this.alias.smimeCertificateId === certificate.id)
82-
return saved || { label: t('mail', 'No certificate') }
95+
return saved || noCertificateOption()
8396
},
8497
set(newVal) {
8598
this.certificate = newVal
@@ -116,13 +129,13 @@ export default {
116129
return cert.hasKey
117130
&& cert.emailAddress === this.alias.alias
118131
&& cert.info.notAfter >= now
119-
&& cert.purposes.sign
120-
&& cert.purposes.encrypt
132+
&& cert.info.purposes.sign
133+
&& cert.info.purposes.encrypt
121134
// TODO: select a separate certificate for encryption?!
122135
})
123136
.map(this.mapCertificateToOption)
124137
.sort(compareSmimeCertificates)
125-
certs.push({ label: t('mail', 'No certificate') })
138+
certs.push(noCertificateOption())
126139

127140
return certs
128141
},
@@ -169,19 +182,37 @@ export default {
169182
commonName: cert.info.commonName ?? cert.info.emailAddress,
170183
expiryDate: moment.unix(cert.info.notAfter).format('LL'),
171184
})
172-
return { ...cert, label }
185+
return {
186+
...cert,
187+
label,
188+
isChainVerified: cert.info.isChainVerified,
189+
}
173190
},
174191
},
175192
}
176193
</script>
177194

178195
<style lang="scss" scoped>
179-
.multiselect--single {
180-
width: 100%;
181-
margin-bottom: 4px;
182-
}
196+
.certificate-settings {
197+
&__alias,
198+
&__certificate {
199+
width: 100%;
200+
}
201+
202+
&__alias + &__certificate {
203+
margin-top: 5px
204+
}
205+
206+
&__submit {
207+
margin-top: 1rem;
208+
}
183209

184-
.button-vue {
185-
margin-top: 4px !important;
210+
&__warning {
211+
display: flex;
212+
align-items: center;
213+
margin-top: 10px;
214+
gap: 10px;
215+
color: var(--color-warning-text);
216+
}
186217
}
187218
</style>

tests/Unit/Service/SmimeServiceTest.php

+19
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use OCA\Mail\Db\SmimeCertificate;
3838
use OCA\Mail\Db\SmimeCertificateMapper;
3939
use OCA\Mail\Model\SmimeCertificateInfo;
40+
use OCA\Mail\Model\SmimeCertificatePurposes;
4041
use OCA\Mail\Service\SmimeService;
4142
use OCP\AppFramework\Utility\ITimeFactory;
4243
use OCP\ICertificateManager;
@@ -398,6 +399,8 @@ public function provideParseCertificateData(): array {
398399
'user',
399400
400401
4862017735,
402+
new SmimeCertificatePurposes(true, true),
403+
true,
401404
),
402405
],
403406
[
@@ -406,6 +409,18 @@ public function provideParseCertificateData(): array {
406409
'cn-only',
407410
408411
4862017727,
412+
new SmimeCertificatePurposes(true, true),
413+
true,
414+
),
415+
],
416+
[
417+
$this->getTestCertificate('[email protected]'),
418+
new SmimeCertificateInfo(
419+
'user',
420+
421+
4862017705,
422+
new SmimeCertificatePurposes(true, true),
423+
false,
409424
),
410425
],
411426
];
@@ -416,6 +431,10 @@ public function provideParseCertificateData(): array {
416431
*/
417432
public function testParseCertificate(SmimeCertificate $certificate,
418433
SmimeCertificateInfo $expected): void {
434+
$this->certificateManager->expects(self::once())
435+
->method('getAbsoluteBundlePath')
436+
->willReturn(__DIR__ . '/../../data/smime-certs/imap.localhost.ca.crt');
437+
419438
$this->assertEquals(
420439
$expected,
421440
$this->smimeService->parseCertificate($certificate->getCertificate()),

0 commit comments

Comments
 (0)