Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions src/lib/components/filesharing/UnsignedConfirmModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<!--
Extra confirmation shown when a recipient chooses to download an
unsigned file (no verifiable sender). The download page only opens
this for the unsigned case — signed files download on a single click —
so the weakest identity claim takes one deliberate step more.
-->
<script lang="ts">
import { _ } from 'svelte-i18n'
import type { Attachment } from 'svelte/attachments'

interface Props {
onConfirm: () => void
onCancel: () => void
}

let { onConfirm, onCancel }: Props = $props()

function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onCancel()
}

/** Move focus to the safe (Cancel) action when the dialog opens so a
* stray Enter press cannot confirm a risky download. */
const focusOnMount: Attachment<HTMLElement> = (node) => {
node.focus()
}
</script>

<svelte:window onkeydown={handleKey} />

<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="unsigned-confirm-title"
aria-describedby="unsigned-confirm-body"
>
<button
class="backdrop-close"
type="button"
aria-label={$_('filesharing.decryptpanel.trustConfirmCancel')}
onclick={onCancel}
></button>

<div class="modal">
<svg
class="modal-icon"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 3L2 21h20L12 3zm0 6v6m0 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<h2 id="unsigned-confirm-title">
{$_('filesharing.decryptpanel.trustConfirmHeader')}
</h2>
<p id="unsigned-confirm-body">
{$_('filesharing.decryptpanel.trustWarnUnsigned')}
</p>
<div class="modal-actions">
<button
type="button"
class="modal-btn modal-btn-cancel"
onclick={onCancel}
{@attach focusOnMount}
>
{$_('filesharing.decryptpanel.trustConfirmCancel')}
</button>
<button
type="button"
class="modal-btn modal-btn-confirm"
onclick={onConfirm}
>
{$_('filesharing.decryptpanel.trustConfirmAccept')}
</button>
</div>
</div>
</div>

<style lang="scss">
.backdrop {
position: fixed;
inset: 0;
background: rgba(3, 14, 23, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1.5rem;
}

.backdrop-close {
position: absolute;
inset: 0;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
}

.modal {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
max-width: 26rem;
width: 100%;
padding: 1.75rem 1.5rem 1.5rem;
background: var(--pg-general-background);
border-radius: var(--pg-border-radius-lg);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
}

.modal-icon {
width: 40px;
height: 40px;
color: var(--pg-input-error);
}

.modal h2 {
margin: 0;
font-size: var(--pg-font-size-lg);
font-weight: var(--pg-font-weight-bold);
color: var(--pg-text);
}

.modal p {
margin: 0;
font-family: var(--pg-font-family);
font-size: var(--pg-font-size-sm);
line-height: 1.5;
color: var(--pg-text);
}

.modal-actions {
display: flex;
gap: 0.6rem;
width: 100%;
margin-top: 0.5rem;
}

.modal-btn {
all: unset;
flex: 1 1 0;
box-sizing: border-box;
text-align: center;
font-family: var(--pg-font-family);
font-size: var(--pg-font-size-md);
font-weight: var(--pg-font-weight-medium);
color: var(--pg-text-secondary);
background: transparent;
border: 1px solid var(--pg-input-normal);
border-radius: var(--pg-border-radius-sm);
padding: 0.55rem 1rem;
cursor: pointer;
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}

.modal-btn:focus-visible {
outline: 2px solid var(--pg-primary);
outline-offset: 2px;
}

.modal-btn-cancel:hover,
.modal-btn-cancel:focus-visible {
color: var(--pg-text);
border-color: var(--pg-input-hover);
background: color-mix(in srgb, var(--pg-text) 8%, transparent);
}

/* Downloading an unsigned file is the risky path, so the confirm
action is marked red rather than neutral. */
.modal-btn-confirm {
color: var(--pg-input-error);
border-color: var(--pg-input-error);
}

.modal-btn-confirm:hover,
.modal-btn-confirm:focus-visible {
color: var(--pg-on-primary);
background: var(--pg-input-error);
border-color: var(--pg-input-error);
}
</style>
105 changes: 104 additions & 1 deletion src/lib/components/filesharing/verifiedAttributes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { verifiedAttributesFor } from './verifiedAttributes'
import {
isUnsignedSender,
isWeakSenderIdentity,
verifiedAttributesFor,
} from './verifiedAttributes'
import type { FriendlySender } from '@e4a/pg-js'

const sender = (attrs: { type: string; value?: string }[]): FriendlySender => ({
Expand Down Expand Up @@ -91,3 +95,102 @@ describe('verifiedAttributesFor', () => {
expect(result).toEqual([])
})
})

describe('isWeakSenderIdentity', () => {
it('does not treat a missing sender as email-only weak', () => {
// An unsigned file has no email to caveat, so the email-only
// warning copy does not apply — flagging it would be inaccurate.
expect(isWeakSenderIdentity(null)).toBe(false)
expect(isWeakSenderIdentity(undefined)).toBe(false)
})

it('does not treat a sender with no verified email as email-only weak', () => {
// No email and nothing else disclosed: there is no email claim to
// warn about, so this is not the email-only case.
expect(isWeakSenderIdentity(sender([]))).toBe(false)
})

it('treats an email-only sender as weak', () => {
expect(
isWeakSenderIdentity(
sender([
{
type: 'pbdf.sidn-pbdf.email.email',
value: 'a@b.com',
},
])
)
).toBe(true)
})

it('treats a sender with any verified non-email attribute as strong', () => {
expect(
isWeakSenderIdentity(
sender([
{
type: 'pbdf.sidn-pbdf.email.email',
value: 'a@b.com',
},
{
type: 'pbdf.gemeente.personalData.fullname',
value: 'R.A. Hensen',
},
])
)
).toBe(false)
})

it('ignores non-email attributes whose value is empty', () => {
// An attribute type without a value carries no signal — it must
// not flip the gate from "weak" to "strong".
expect(
isWeakSenderIdentity(
sender([
{
type: 'pbdf.sidn-pbdf.email.email',
value: 'a@b.com',
},
{
type: 'pbdf.gemeente.personalData.fullname',
value: undefined,
},
])
)
).toBe(true)
})
})

describe('isUnsignedSender', () => {
it('treats a missing sender as unsigned', () => {
expect(isUnsignedSender(null)).toBe(true)
expect(isUnsignedSender(undefined)).toBe(true)
})

it('treats a sender with no verified email as unsigned', () => {
expect(isUnsignedSender(sender([]))).toBe(true)
})

it('does not treat an email-only sender as unsigned', () => {
expect(
isUnsignedSender(
sender([
{ type: 'pbdf.sidn-pbdf.email.email', value: 'a@b.com' },
])
)
).toBe(false)
})

it('does not treat a fully verified sender as unsigned', () => {
expect(
isUnsignedSender(
sender([
{ type: 'pbdf.sidn-pbdf.email.email', value: 'a@b.com' },
{
type: 'pbdf.gemeente.personalData.fullname',
value: 'R.A. Hensen',
},
])
)
).toBe(false)
})
})
21 changes: 21 additions & 0 deletions src/lib/components/filesharing/verifiedAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,24 @@ export function verifiedAttributesFor(
value: a.value as string,
}))
}

/** A sender that verified an email address but disclosed nothing beyond
* it is a "weak" identity claim — anyone who controls the mailbox could
* have signed. The download-page trust gate uses this to escalate the
* warning. A missing sender (an unsigned file) is not "weak email-only":
* there is no email to caveat, so the email-only warning does not apply. */
export function isWeakSenderIdentity(
sender: FriendlySender | null | undefined
): boolean {
return !!sender?.email && verifiedAttributesFor(sender).length === 0
}

/** A file with no verifiable sender at all: it was not signed, or the
* signature carries no email (the public signing identity). This is the
* weakest case — there is no identity claim to evaluate — so the download
* gate shows the strongest warning and time-locks the download button. */
export function isUnsignedSender(
sender: FriendlySender | null | undefined
): boolean {
return !sender?.email
}
4 changes: 4 additions & 0 deletions src/lib/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ body {
--pg-input-hover: #45545e;
--pg-input-active: #2b343b;
--pg-input-error: #b61616;
--pg-warning: #c2710c;
--pg-success: #1f7a4d;
--pg-on-primary: #ffffff;
--pg-disabled-background: #6b7d8a;
--pg-disabled-foreground: #ffffff;
Expand All @@ -93,6 +95,8 @@ body {
--pg-input-hover: #98a8b3;
--pg-input-active: #c4cdd4;
--pg-input-error: #de3030;
--pg-warning: #f5a623;
--pg-success: #3ea674;

.invert {
filter: invert(100%);
Expand Down
12 changes: 11 additions & 1 deletion src/lib/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,18 @@
"askDownloadText": "Press the button below to start decrypting and downloading your files",
"doneMessage": "Your files are being downloaded and decrypted",
"doneMessageComplete": "Your files have been downloaded and decrypted",
"readyToDownload": "Your files have been decrypted and are ready to download",
"verifiedEmail": "The files are from",
"verifiedExtra": "The files were signed using",
"trustHeader": "Do you trust this sender?",
"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.",
"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.",
"trustConfirmHeader": "Download an unsigned file?",
"trustConfirmAccept": "Download anyway",
"trustConfirmCancel": "Cancel",
"trustAccept": "Download files",
"trustDecline": "Don't download",
"discardedTitle": "Files discarded",
"discardedBody": "The files were not downloaded. If you want them after all, ask the sender to share again.",
"notFoundTitle": "These files no longer exist",
"notFoundSubtitle": "The link may have expired, or there may be an error in it.",
"notFoundMessage": "Ask the sender to send the files <strong>once more</strong>.",
Expand Down
12 changes: 11 additions & 1 deletion src/lib/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,18 @@
"askDownloadText": "Druk op onderstaande knop om de bestanden te ontsleutelen en downloaden.",
"doneMessage": "Je bestanden worden gedownload en ontsleuteld",
"doneMessageComplete": "Je bestanden zijn gedownload en ontsleuteld",
"readyToDownload": "Je bestanden zijn ontsleuteld en klaar om te downloaden",
"verifiedEmail": "De bestanden komen van",
"verifiedExtra": "De bestanden zijn ondertekend met",
"trustHeader": "Vertrouw je deze afzender?",
"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.",
"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.",
"trustConfirmHeader": "Een niet-ondertekend bestand downloaden?",
"trustConfirmAccept": "Toch downloaden",
"trustConfirmCancel": "Annuleren",
"trustAccept": "Bestanden downloaden",
"trustDecline": "Niet downloaden",
"discardedTitle": "Bestanden weggegooid",
"discardedBody": "De bestanden zijn niet gedownload. Wil je ze toch ontvangen, vraag de afzender ze opnieuw te delen.",
"notFoundTitle": "Deze bestanden bestaan niet (meer)",
"notFoundSubtitle": "Het kan zijn dat de link is verlopen, of dat er een fout in zit.",
"notFoundMessage": "Vraag de verzender de bestanden <strong>nog een keer te versturen</strong>.",
Expand Down
Loading