diff --git a/docs/adr/0004-untrusted-releases-and-artifacts.md b/docs/adr/0004-untrusted-releases-and-artifacts.md new file mode 100644 index 000000000..3fbf380ad --- /dev/null +++ b/docs/adr/0004-untrusted-releases-and-artifacts.md @@ -0,0 +1,82 @@ +# 4. Untrusted releases and artifacts + +Date: 2026-10-06 + +## Status + +Proposed + +## Context + +Releases and their artifacts are COBs, so anyone can create a release in a +repo they replicate, and anyone can register an artifact on any release, +including a delegate's. An artifact is a file a user downloads and may run. +A release that sits in a well-known repo gets some of that repo's trust in +the eyes of a user who doesn't know what a delegate is. This makes it easy +to trick a user into downloading something malicious. + +Trust is set at two levels: + +- **Release:** its creator decides the title, tag and commit. +- **Artifact:** its author decides the bytes behind the CID. + +The two can differ. A delegate's release can carry an artifact a +non-delegate registered, and a delegate can register an artifact on a +non-delegate's release. + +The app, like radicle-explorer, splits releases and artifacts into two +disjoint scopes by author: Delegates and Others. In the code the scopes are +`trusted` and `untrusted`. The discussion is in the +[Artifacts topic](https://radicle.zulipchat.com/#narrow/channel/444463-Desktop/topic/Artifacts/with/624009755) +on Zulip. + +## Decision + +**Trust follows delegates.** A release or artifact is trusted when its +creator or author is a delegate of the repo, and untrusted otherwise. The +UI keeps the labels Delegates and Others, as "Untrusted" reads as too +alarming for content that is often harmless. + +**Warn where untrusted content shows.** A short warning +(`UntrustedWarning.svelte`) says the content is not from a delegate and to +download it only if the user trusts its author. It stays small, so it +informs without alarm. It shows: + +- above the Others releases list; +- on a release by a non-delegate, for the whole release; +- on a delegate's release, while it shows artifacts by non-delegates; +- in the download popover of an artifact by a non-delegate, whatever the + release, because that is where the user acts; +- in the browser tab of a delegate's artifact, while it lists links added + by non-delegates, as those downloads are not checked against the CID. + +**Count only delegate attestations.** The attestation badge on an artifact +counts delegates only, as anyone can make keys that attest their own +artifact. The full list still shows every attestation. + +**Keep untrusted content visible.** Others' releases and artifacts stay +listed by default, behind their own filter. + +## Alternatives considered + +- **Hide Others by default**, behind a global setting. This protects users + best, and avoids showing links to malware at all, which is a reputation + risk a warning doesn't remove. It also hides legitimate contributions, + such as builds by a release manager who is not a delegate. It is still + open. +- **Rename Delegates to Official** or Others to Untrusted. This changes + only the wording and leaves the warning to a single label. +- **A list of trusted publishers in the repo identity**, like canonical ref + rules. This lets non-delegates publish trusted releases, but adds a + second trust model. Linking a release to a canonical annotated tag gives + part of this already, so canonical refs are the preferred path. + +## Consequences + +- Users see a warning before they download an untrusted artifact, also when + it sits on a trusted release. +- A trusted artifact on an untrusted release still shows the release + warning, as its title, tag and commit are not trusted. +- Delegation is the only trust signal. Release managers who are not + delegates show as Others until releases can use canonical ref rules. +- radicle-explorer should show the same warnings so that both clients agree. diff --git a/src/components/ArtifactDownloadButton.svelte b/src/components/ArtifactDownloadButton.svelte index 07e75540d..5354c5249 100644 --- a/src/components/ArtifactDownloadButton.svelte +++ b/src/components/ArtifactDownloadButton.svelte @@ -17,14 +17,13 @@ import ExternalLink from "@app/components/ExternalLink.svelte"; import Icon from "@app/components/Icon.svelte"; import Popover from "@app/components/Popover.svelte"; + import UntrustedWarning from "@app/components/UntrustedWarning.svelte"; interface Props { artifact: Artifact; delegateIds: Set; releaseId: string; rid: string; - /// Whether the node already seeds this artifact, asked of the node rather - /// than assumed from what this component did. seeding: boolean; onDownloaded: () => void; } @@ -40,8 +39,6 @@ let activeTab: "app" | "cli" | "browser" = $state("app"); let expanded = $state(false); - // Which transfer is running: a download writes to disk, a fetch only fills - // the node's store. let running: "download" | "fetch" | undefined = $state(); let progress: ArtifactProgress | undefined = $state(); let downloadError: string | undefined = $state(); @@ -54,8 +51,6 @@ } }); - // Clear the outcome of a finished download once the popover closes, so a - // later visit starts fresh. $effect(() => { if (!expanded && !running) { finished = undefined; @@ -63,8 +58,6 @@ } }); - // The node reports byte movement per CID, so a shared event channel - // is filtered down to this artifact. $effect(() => { // Events only exist under Tauri, not against the test HTTP API. if (!window.__TAURI_INTERNALS__) { @@ -146,12 +139,13 @@ const webLocations = $derived( releases.webLocations(artifact.locations, delegateIds), ); - // Any location at all can be fetched with the CLI, web locations included. + const authorTrusted = $derived(delegateIds.has(artifact.author.did)); + const linksByOthers = $derived( + webLocations.some(l => !delegateIds.has(l.user.did)), + ); const downloadable = $derived(seeding || artifact.locations.length > 0); const downloadLabel = $derived(seeding ? "Save" : "Download"); - // The app and CLI tabs fetch through the artifact node, so they are only - // usable while it answers. The browser tab does not need it. const nodeRunning = $derived($artifactNodeRunning); const START_COMMAND = "rad-artifact node start"; const command = $derived( @@ -171,6 +165,9 @@ .start-command { margin-bottom: 0.75rem; } + .untrusted { + margin-bottom: 0.75rem; + } .tabs { display: flex; gap: 2px; @@ -265,6 +262,12 @@ {#snippet popover()}
+ {#if !authorTrusted} +
+ +
+ {/if}
+ {#if authorTrusted && linksByOthers} +
+ +
+ {/if}
{#each webLocations as location (`${location.user.did}:${location.url}`)}
diff --git a/src/components/ArtifactRow.svelte b/src/components/ArtifactRow.svelte new file mode 100644 index 000000000..834c3090c --- /dev/null +++ b/src/components/ArtifactRow.svelte @@ -0,0 +1,1167 @@ + + + + +
+
+ + {#if size} + {size} + {/if} +
+ {#if seeding} + + {:else if !artifact.redacted} + {#if reseedError} + + {reseedError} + + {/if} + + {/if} + +
+
+ +
+ + + + + + {#if delegateIds.has(artifact.author.did)} + + {/if} + + {#if trust} + + + {trust} + + {/if} + {#if unseedFailed} + Could not unseed. + {/if} +
+ + {#if open} +
+
+
+ Metadata + {metadata.length} + {#if editable && editing !== ""} + + {/if} +
+ + {#if metadata.length === 0 && editing !== ""} +
No metadata
+ {/if} + + {#each metadata as [key, value] (key)} + {#if editing === key} +
+ + + + + saveMetadata(key)} /> + + + +
+ {:else} +
+ {key} + + {displayMetadataValue(value)} + + {#if editable} + + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} +
+
+
+ Remove "{key}"? +
+
+ The entry is dropped from the release for everyone + who replicates it. You can set it again afterwards. +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/if} + {/each} + + {#if editing === ""} +
+ + + + + saveMetadata("")} /> + + + +
+ {/if} + + {#if metadataError} +
{metadataError}
+ {/if} +
+ +
+
+ Attestations + {attestations.length} + {#if canAttest(artifact, ownDid)} + + {/if} +
+ {#if attestError} +
{attestError}
+ {/if} + {#if attestations.length === 0} +
Nobody has attested to this artifact
+ {:else} +
+ {#each attestations as node (node.did)} +
+ + + {#if delegateIds.has(node.did)} + + {/if} +
+ {/each} +
+ {/if} +
+ +
+
+ Redactions + {redactions.length} + + {#if editable && !redactions.some(r => r.user.did === ownDid)} + + {/if} +
+ {#if redactions.length === 0} +
No redactions
+ {:else} +
+ {#each redactions as redaction (redaction.user.did)} +
+ + {#if delegateIds.has(redaction.user.did)} + + {/if} + + {redaction.reason || "No reason"} + +
+ {/each} +
+ {/if} +
+ +
+
+ Locations + {artifact.locations.length} + {#if !artifact.redacted && !addingLocation} + + {/if} +
+ {#if addingLocation} +
+ + + + + +
+ {/if} + {#if locationError} +
{locationError}
+ {/if} + {#if artifact.locations.length === 0} + {#if !addingLocation} +
No locations
+ {/if} + {:else} +
+ {#each locations as group (group.user.did)} +
+
+ + {#if delegateIds.has(group.user.did)} + + {/if} +
+ {#each group.urls as url (url)} +
+ + + + {#if group.user.did === ownDid} + + + {#snippet toggle(onclick)} + + {/snippet} + {#snippet popover()} + + {@const unseeds = + url.startsWith("radiroh:") && seeding} + {@const busy = savingLocation || unseeding} +
+
+
+ {unseeds + ? "Unseed this artifact?" + : "Remove this location?"} +
+
+ {#if unseeds} + This is your node's location. Removing it + also unseeds it from your node seeding the + artifact. + {:else} + Peers stop downloading the artifact from it. + {/if} +
+
+
+ + +
+
+ {/snippet} +
+
+ {/if} +
+ {/each} +
+ {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/src/components/UntrustedWarning.svelte b/src/components/UntrustedWarning.svelte new file mode 100644 index 000000000..b3efcbb9c --- /dev/null +++ b/src/components/UntrustedWarning.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/releases.ts b/src/lib/releases.ts index 0ca162502..50eda0ef9 100644 --- a/src/lib/releases.ts +++ b/src/lib/releases.ts @@ -22,18 +22,12 @@ export function releaseListScope( export interface ArtifactView { scope: ReleaseScope; shown: Artifact[]; - // Redacted artifacts within the current author scope. redactedCount: number; - // What each scope would show, so an artifact hidden as redacted never - // counts towards a scope. counts: Record; - // Filter only when both scopes hold something. showFilters: boolean; } -// Split a release's artifacts into two disjoint scopes by author. Show the -// scope asked for unless it is empty, and hide artifacts redacted by their -// author or a delegate unless asked to show them. +// Falls back to the other scope when the one asked for is empty. export function artifactView( artifacts: Artifact[], delegates: Set, @@ -70,7 +64,6 @@ export function redactedByDelegate( return artifact.redactions.some(r => delegates.has(r.user.did)); } -// Group locations by the contributing node, preserving order. export function locationsByNode( locations: Location[], ): { user: Author; urls: string[] }[] { @@ -83,9 +76,7 @@ export function locationsByNode( return [...groups.values()]; } -// Order a node-keyed list so delegates come first. The backend returns these -// sorted by DID (arbitrary to a reader); sort is stable, so the original -// order is preserved within each group. +// The backend sorts by DID, which means nothing to a reader. export function delegatesFirst( items: T[], did: (item: T) => string, @@ -96,9 +87,6 @@ export function delegatesFirst( ); } -// Locations a browser can open, as opposed to ones fetched from a seeder -// over the radicle-artifact protocol. Delegate locations come first: they -// are the ones a reader can trust most. export function webLocations( locations: Location[], delegates: Set, @@ -119,8 +107,6 @@ export function attestedBy( return attestations.filter(a => delegates.has(a.did)).length; } -// Only metadata by the author or a delegate is shown, so only they get an -// editor. export function canEditMetadata( artifact: Artifact, ownDid: string, @@ -140,9 +126,7 @@ export function canAttest(artifact: Artifact, ownDid: string): boolean { ); } -// The COB stores values as free-form JSON. Text that parses as JSON is sent -// as that value, so numbers and booleans round-trip; anything else is sent -// as a plain string. +// Text that parses as JSON is sent as that value, so numbers round-trip. export function parseMetadataValue(input: string): unknown { try { return JSON.parse(input); @@ -155,8 +139,6 @@ export function displayMetadataValue(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value); } -// Commits whose SHA starts with, or whose summary contains, the query. A -// pasted commit outside the loaded history still shows as a choice. export function matchCommits( commits: Commit[], query: string, @@ -179,3 +161,16 @@ export function canonicalTags(refs: RepoRefs): { name: string; tag: Tag }[] { .map(([name, tag]) => ({ name, tag })) .sort((a, b) => b.tag.timestamp - a.tag.timestamp); } + +// A release by a non-delegate is untrusted as a whole. On a delegate's +// release, only the artifacts registered by others are. +export function releaseWarning( + creatorDid: string, + delegates: Set, + artifactScope: ReleaseScope, +): "release" | "artifacts" | undefined { + if (!delegates.has(creatorDid)) { + return "release"; + } + return artifactScope === "untrusted" ? "artifacts" : undefined; +} diff --git a/src/views/repo/Release.svelte b/src/views/repo/Release.svelte index 4a6598770..5b3b40307 100644 --- a/src/views/repo/Release.svelte +++ b/src/views/repo/Release.svelte @@ -1,56 +1,32 @@