Skip to content

Commit 464aa8f

Browse files
authored
feat(decrypt): add trust-confirmation gate before files reach disk (#258)
* feat(decrypt): add trust-confirmation gate before files reach disk The download page used to auto-trigger browser downloads the moment decryption finished. Recipients now see a Confirm panel — same Done layout (banner + file list + pill chips of verified attributes) plus two neutral buttons — and decide whether to keep the files. Decline discards the in-memory blob; accept triggers the download as before. When the sender disclosed nothing beyond their email, a strong warning band appears above the buttons. Email alone is a weak claim (anyone with control of the mailbox could have signed), so the gate is the right moment to surface that risk. UX polish that came out of the same round: - Widen the column to 350px and let the Yivi QR fill it instead of capping at 330px — the column now visibly wraps the QR. - New `--pg-success` brand token (light/dark) used only for the accept button's hover/focus tint; decline tints to the existing `--pg-input-error`. - /debug/download-flow: ALL_STATES gains `Confirm` + `Discarded`, scenarios route through Confirm and pause there for the developer to click one of the trust buttons, force-state highlight tracks the live `downloadState` in real time, the page locks to one viewport height, and the global footer is hidden on `/debug/*`. Test: - New `isWeakSenderIdentity` helper alongside `verifiedAttributesFor`, unit-tested for null, email-only, with-private-attrs, and empty-value-attribute cases. * fix(decrypt): address review on trust-confirmation gate - Confirm gate showed doneMessageComplete ("files have been downloaded and decrypted") before files reach disk. Add a readyToDownload message and use it there; the banner no longer contradicts the gate and CTA. - Migrate the Ready (QR-scan) sender-section to the attr-chips markup. It still referenced the removed verifiedExtra i18n key and attr-list CSS, so disclosed attributes rendered as a literal key in an unstyled <dl>. - Scope isWeakSenderIdentity to senders that actually verified an email. A missing/unsigned sender no longer triggers the email-only warning, whose copy does not apply when there is no email to caveat. * feat(decrypt): warn on unsigned files and time-lock their download An unsigned file (no verifiable sender at all) is the weakest case of all, yet it previously showed no caution. Add a dedicated, stronger warning for it and force the recipient to read before they can accept: - isUnsignedSender() helper (sender has no verified email) + unit tests. - Confirm gate now branches: unsigned => louder trustWarnUnsigned band (thicker border, more saturated fill); email-only keeps the existing trustWarnEmailOnly band; verified senders show none. - For the unsigned case only, the download button starts disabled and fills left-to-right over 5s (TRUST_UNLOCK_MS) before activating, so the user cannot click through without pausing on the warning. Decline stays enabled throughout. Other cases remain instantly clickable. - New i18n key trustWarnUnsigned (en + nl). - debug/download-flow: replace the email-only toggle with a three-way sender-identity selector (strong / email-only / unsigned) and mirror the warning branch + time-locked button so the preview stays faithful; also sync its Confirm banner to readyToDownload and hide sender sections when there is no email. * refactor(decrypt): replace unsigned time-lock with a confirmation modal The 5s greyed/progress-bar download button read as broken UI. Replace it with an explicit confirmation step, and refine the warnings per review: - New shared UnsignedConfirmModal: on an unsigned file, "Download files" opens a modal (red warning, Cancel / Download anyway, Esc + click- outside to close, Cancel focused on open) instead of a single click. Signed files still download on one click. Used by both the download page and the debug preview. - Remove the time-lock state/effect/timer and the locked-button CSS. - Email-only warning is now orange (new --pg-warning token); unsigned stays red but is no longer bold, so severity reads through colour. - Strip em-dashes from both warning messages (en + nl). - New i18n keys: trustConfirmHeader / trustConfirmAccept / trustConfirmCancel.
1 parent 8d1f5dd commit 464aa8f

9 files changed

Lines changed: 1095 additions & 162 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<!--
2+
Extra confirmation shown when a recipient chooses to download an
3+
unsigned file (no verifiable sender). The download page only opens
4+
this for the unsigned case — signed files download on a single click —
5+
so the weakest identity claim takes one deliberate step more.
6+
-->
7+
<script lang="ts">
8+
import { _ } from 'svelte-i18n'
9+
import type { Attachment } from 'svelte/attachments'
10+
11+
interface Props {
12+
onConfirm: () => void
13+
onCancel: () => void
14+
}
15+
16+
let { onConfirm, onCancel }: Props = $props()
17+
18+
function handleKey(e: KeyboardEvent) {
19+
if (e.key === 'Escape') onCancel()
20+
}
21+
22+
/** Move focus to the safe (Cancel) action when the dialog opens so a
23+
* stray Enter press cannot confirm a risky download. */
24+
const focusOnMount: Attachment<HTMLElement> = (node) => {
25+
node.focus()
26+
}
27+
</script>
28+
29+
<svelte:window onkeydown={handleKey} />
30+
31+
<div
32+
class="backdrop"
33+
role="dialog"
34+
aria-modal="true"
35+
aria-labelledby="unsigned-confirm-title"
36+
aria-describedby="unsigned-confirm-body"
37+
>
38+
<button
39+
class="backdrop-close"
40+
type="button"
41+
aria-label={$_('filesharing.decryptpanel.trustConfirmCancel')}
42+
onclick={onCancel}
43+
></button>
44+
45+
<div class="modal">
46+
<svg
47+
class="modal-icon"
48+
viewBox="0 0 24 24"
49+
fill="none"
50+
xmlns="http://www.w3.org/2000/svg"
51+
aria-hidden="true"
52+
>
53+
<path
54+
d="M12 3L2 21h20L12 3zm0 6v6m0 2v2"
55+
stroke="currentColor"
56+
stroke-width="2"
57+
stroke-linecap="round"
58+
stroke-linejoin="round"
59+
/>
60+
</svg>
61+
<h2 id="unsigned-confirm-title">
62+
{$_('filesharing.decryptpanel.trustConfirmHeader')}
63+
</h2>
64+
<p id="unsigned-confirm-body">
65+
{$_('filesharing.decryptpanel.trustWarnUnsigned')}
66+
</p>
67+
<div class="modal-actions">
68+
<button
69+
type="button"
70+
class="modal-btn modal-btn-cancel"
71+
onclick={onCancel}
72+
{@attach focusOnMount}
73+
>
74+
{$_('filesharing.decryptpanel.trustConfirmCancel')}
75+
</button>
76+
<button
77+
type="button"
78+
class="modal-btn modal-btn-confirm"
79+
onclick={onConfirm}
80+
>
81+
{$_('filesharing.decryptpanel.trustConfirmAccept')}
82+
</button>
83+
</div>
84+
</div>
85+
</div>
86+
87+
<style lang="scss">
88+
.backdrop {
89+
position: fixed;
90+
inset: 0;
91+
background: rgba(3, 14, 23, 0.55);
92+
display: flex;
93+
align-items: center;
94+
justify-content: center;
95+
z-index: 1000;
96+
padding: 1.5rem;
97+
}
98+
99+
.backdrop-close {
100+
position: absolute;
101+
inset: 0;
102+
background: transparent;
103+
border: 0;
104+
padding: 0;
105+
cursor: pointer;
106+
}
107+
108+
.modal {
109+
position: relative;
110+
z-index: 1;
111+
display: flex;
112+
flex-direction: column;
113+
align-items: center;
114+
text-align: center;
115+
gap: 0.75rem;
116+
max-width: 26rem;
117+
width: 100%;
118+
padding: 1.75rem 1.5rem 1.5rem;
119+
background: var(--pg-general-background);
120+
border-radius: var(--pg-border-radius-lg);
121+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
122+
}
123+
124+
.modal-icon {
125+
width: 40px;
126+
height: 40px;
127+
color: var(--pg-input-error);
128+
}
129+
130+
.modal h2 {
131+
margin: 0;
132+
font-size: var(--pg-font-size-lg);
133+
font-weight: var(--pg-font-weight-bold);
134+
color: var(--pg-text);
135+
}
136+
137+
.modal p {
138+
margin: 0;
139+
font-family: var(--pg-font-family);
140+
font-size: var(--pg-font-size-sm);
141+
line-height: 1.5;
142+
color: var(--pg-text);
143+
}
144+
145+
.modal-actions {
146+
display: flex;
147+
gap: 0.6rem;
148+
width: 100%;
149+
margin-top: 0.5rem;
150+
}
151+
152+
.modal-btn {
153+
all: unset;
154+
flex: 1 1 0;
155+
box-sizing: border-box;
156+
text-align: center;
157+
font-family: var(--pg-font-family);
158+
font-size: var(--pg-font-size-md);
159+
font-weight: var(--pg-font-weight-medium);
160+
color: var(--pg-text-secondary);
161+
background: transparent;
162+
border: 1px solid var(--pg-input-normal);
163+
border-radius: var(--pg-border-radius-sm);
164+
padding: 0.55rem 1rem;
165+
cursor: pointer;
166+
transition:
167+
background 0.2s ease,
168+
color 0.2s ease,
169+
border-color 0.2s ease;
170+
}
171+
172+
.modal-btn:focus-visible {
173+
outline: 2px solid var(--pg-primary);
174+
outline-offset: 2px;
175+
}
176+
177+
.modal-btn-cancel:hover,
178+
.modal-btn-cancel:focus-visible {
179+
color: var(--pg-text);
180+
border-color: var(--pg-input-hover);
181+
background: color-mix(in srgb, var(--pg-text) 8%, transparent);
182+
}
183+
184+
/* Downloading an unsigned file is the risky path, so the confirm
185+
action is marked red rather than neutral. */
186+
.modal-btn-confirm {
187+
color: var(--pg-input-error);
188+
border-color: var(--pg-input-error);
189+
}
190+
191+
.modal-btn-confirm:hover,
192+
.modal-btn-confirm:focus-visible {
193+
color: var(--pg-on-primary);
194+
background: var(--pg-input-error);
195+
border-color: var(--pg-input-error);
196+
}
197+
</style>

src/lib/components/filesharing/verifiedAttributes.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from 'vitest'
2-
import { verifiedAttributesFor } from './verifiedAttributes'
2+
import {
3+
isUnsignedSender,
4+
isWeakSenderIdentity,
5+
verifiedAttributesFor,
6+
} from './verifiedAttributes'
37
import type { FriendlySender } from '@e4a/pg-js'
48

59
const sender = (attrs: { type: string; value?: string }[]): FriendlySender => ({
@@ -91,3 +95,102 @@ describe('verifiedAttributesFor', () => {
9195
expect(result).toEqual([])
9296
})
9397
})
98+
99+
describe('isWeakSenderIdentity', () => {
100+
it('does not treat a missing sender as email-only weak', () => {
101+
// An unsigned file has no email to caveat, so the email-only
102+
// warning copy does not apply — flagging it would be inaccurate.
103+
expect(isWeakSenderIdentity(null)).toBe(false)
104+
expect(isWeakSenderIdentity(undefined)).toBe(false)
105+
})
106+
107+
it('does not treat a sender with no verified email as email-only weak', () => {
108+
// No email and nothing else disclosed: there is no email claim to
109+
// warn about, so this is not the email-only case.
110+
expect(isWeakSenderIdentity(sender([]))).toBe(false)
111+
})
112+
113+
it('treats an email-only sender as weak', () => {
114+
expect(
115+
isWeakSenderIdentity(
116+
sender([
117+
{
118+
type: 'pbdf.sidn-pbdf.email.email',
119+
value: 'a@b.com',
120+
},
121+
])
122+
)
123+
).toBe(true)
124+
})
125+
126+
it('treats a sender with any verified non-email attribute as strong', () => {
127+
expect(
128+
isWeakSenderIdentity(
129+
sender([
130+
{
131+
type: 'pbdf.sidn-pbdf.email.email',
132+
value: 'a@b.com',
133+
},
134+
{
135+
type: 'pbdf.gemeente.personalData.fullname',
136+
value: 'R.A. Hensen',
137+
},
138+
])
139+
)
140+
).toBe(false)
141+
})
142+
143+
it('ignores non-email attributes whose value is empty', () => {
144+
// An attribute type without a value carries no signal — it must
145+
// not flip the gate from "weak" to "strong".
146+
expect(
147+
isWeakSenderIdentity(
148+
sender([
149+
{
150+
type: 'pbdf.sidn-pbdf.email.email',
151+
value: 'a@b.com',
152+
},
153+
{
154+
type: 'pbdf.gemeente.personalData.fullname',
155+
value: undefined,
156+
},
157+
])
158+
)
159+
).toBe(true)
160+
})
161+
})
162+
163+
describe('isUnsignedSender', () => {
164+
it('treats a missing sender as unsigned', () => {
165+
expect(isUnsignedSender(null)).toBe(true)
166+
expect(isUnsignedSender(undefined)).toBe(true)
167+
})
168+
169+
it('treats a sender with no verified email as unsigned', () => {
170+
expect(isUnsignedSender(sender([]))).toBe(true)
171+
})
172+
173+
it('does not treat an email-only sender as unsigned', () => {
174+
expect(
175+
isUnsignedSender(
176+
sender([
177+
{ type: 'pbdf.sidn-pbdf.email.email', value: 'a@b.com' },
178+
])
179+
)
180+
).toBe(false)
181+
})
182+
183+
it('does not treat a fully verified sender as unsigned', () => {
184+
expect(
185+
isUnsignedSender(
186+
sender([
187+
{ type: 'pbdf.sidn-pbdf.email.email', value: 'a@b.com' },
188+
{
189+
type: 'pbdf.gemeente.personalData.fullname',
190+
value: 'R.A. Hensen',
191+
},
192+
])
193+
)
194+
).toBe(false)
195+
})
196+
})

src/lib/components/filesharing/verifiedAttributes.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,24 @@ export function verifiedAttributesFor(
2323
value: a.value as string,
2424
}))
2525
}
26+
27+
/** A sender that verified an email address but disclosed nothing beyond
28+
* it is a "weak" identity claim — anyone who controls the mailbox could
29+
* have signed. The download-page trust gate uses this to escalate the
30+
* warning. A missing sender (an unsigned file) is not "weak email-only":
31+
* there is no email to caveat, so the email-only warning does not apply. */
32+
export function isWeakSenderIdentity(
33+
sender: FriendlySender | null | undefined
34+
): boolean {
35+
return !!sender?.email && verifiedAttributesFor(sender).length === 0
36+
}
37+
38+
/** A file with no verifiable sender at all: it was not signed, or the
39+
* signature carries no email (the public signing identity). This is the
40+
* weakest case — there is no identity claim to evaluate — so the download
41+
* gate shows the strongest warning and time-locks the download button. */
42+
export function isUnsignedSender(
43+
sender: FriendlySender | null | undefined
44+
): boolean {
45+
return !sender?.email
46+
}

src/lib/global.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ body {
7070
--pg-input-hover: #45545e;
7171
--pg-input-active: #2b343b;
7272
--pg-input-error: #b61616;
73+
--pg-warning: #c2710c;
74+
--pg-success: #1f7a4d;
7375
--pg-on-primary: #ffffff;
7476
--pg-disabled-background: #6b7d8a;
7577
--pg-disabled-foreground: #ffffff;
@@ -93,6 +95,8 @@ body {
9395
--pg-input-hover: #98a8b3;
9496
--pg-input-active: #c4cdd4;
9597
--pg-input-error: #de3030;
98+
--pg-warning: #f5a623;
99+
--pg-success: #3ea674;
96100

97101
.invert {
98102
filter: invert(100%);

src/lib/locales/en.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,18 @@
170170
"askDownloadText": "Press the button below to start decrypting and downloading your files",
171171
"doneMessage": "Your files are being downloaded and decrypted",
172172
"doneMessageComplete": "Your files have been downloaded and decrypted",
173+
"readyToDownload": "Your files have been decrypted and are ready to download",
173174
"verifiedEmail": "The files are from",
174-
"verifiedExtra": "The files were signed using",
175+
"trustHeader": "Do you trust this sender?",
176+
"trustWarnEmailOnly": "The sender only verified an email address. Anyone with access to that mailbox could have signed these files. Check that the address truly belongs to who you expect, ideally via a channel other than email, before downloading.",
177+
"trustWarnUnsigned": "This file is not signed. There is no verified sender at all, so anyone could have created it and there is no way to confirm where it came from. Only continue if you are certain you trust the source.",
178+
"trustConfirmHeader": "Download an unsigned file?",
179+
"trustConfirmAccept": "Download anyway",
180+
"trustConfirmCancel": "Cancel",
181+
"trustAccept": "Download files",
182+
"trustDecline": "Don't download",
183+
"discardedTitle": "Files discarded",
184+
"discardedBody": "The files were not downloaded. If you want them after all, ask the sender to share again.",
175185
"notFoundTitle": "These files no longer exist",
176186
"notFoundSubtitle": "The link may have expired, or there may be an error in it.",
177187
"notFoundMessage": "Ask the sender to send the files <strong>once more</strong>.",

src/lib/locales/nl.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,18 @@
170170
"askDownloadText": "Druk op onderstaande knop om de bestanden te ontsleutelen en downloaden.",
171171
"doneMessage": "Je bestanden worden gedownload en ontsleuteld",
172172
"doneMessageComplete": "Je bestanden zijn gedownload en ontsleuteld",
173+
"readyToDownload": "Je bestanden zijn ontsleuteld en klaar om te downloaden",
173174
"verifiedEmail": "De bestanden komen van",
174-
"verifiedExtra": "De bestanden zijn ondertekend met",
175+
"trustHeader": "Vertrouw je deze afzender?",
176+
"trustWarnEmailOnly": "De afzender heeft alleen een e-mailadres geverifieerd. Iedereen met toegang tot die mailbox kan deze bestanden hebben ondertekend. Controleer of het adres echt van degene is die je verwacht, bij voorkeur via een ander kanaal dan e-mail, voordat je downloadt.",
177+
"trustWarnUnsigned": "Dit bestand is niet ondertekend. Er is geen geverifieerde afzender, dus iedereen kan het gemaakt hebben en er is geen manier om te bevestigen waar het vandaan komt. Ga alleen verder als je zeker weet dat je de bron vertrouwt.",
178+
"trustConfirmHeader": "Een niet-ondertekend bestand downloaden?",
179+
"trustConfirmAccept": "Toch downloaden",
180+
"trustConfirmCancel": "Annuleren",
181+
"trustAccept": "Bestanden downloaden",
182+
"trustDecline": "Niet downloaden",
183+
"discardedTitle": "Bestanden weggegooid",
184+
"discardedBody": "De bestanden zijn niet gedownload. Wil je ze toch ontvangen, vraag de afzender ze opnieuw te delen.",
175185
"notFoundTitle": "Deze bestanden bestaan niet (meer)",
176186
"notFoundSubtitle": "Het kan zijn dat de link is verlopen, of dat er een fout in zit.",
177187
"notFoundMessage": "Vraag de verzender de bestanden <strong>nog een keer te versturen</strong>.",

0 commit comments

Comments
 (0)