mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Warn about releases and artifacts by others
- Add an UntrustedWarning banner - Warn on the Others releases list - Warn on a release by a non-delegate - Warn on artifacts by non-delegates on a delegate release - Warn in the download popover for a non-delegate artifact - Add E2E tests for both release cases Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Rūdolfs Ošiņš
parent
e930bfd397
commit
f90db0986a
@@ -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.
|
||||
@@ -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<string>;
|
||||
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()}
|
||||
<div class="popover">
|
||||
{#if !authorTrusted}
|
||||
<div class="untrusted">
|
||||
<UntrustedWarning
|
||||
text="Not from a delegate. Only download if you trust the author." />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="tabs">
|
||||
<Button
|
||||
styleWidth="100%"
|
||||
@@ -393,6 +396,12 @@
|
||||
<Icon name="warning" />
|
||||
These downloads are not checked against the CID.
|
||||
</div>
|
||||
{#if authorTrusted && linksByOthers}
|
||||
<div class="untrusted">
|
||||
<UntrustedWarning
|
||||
text="Links without a delegate badge are not from a delegate. Only use them if you trust who added them." />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="locations">
|
||||
{#each webLocations as location (`${location.user.did}:${location.url}`)}
|
||||
<div class="location">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const { text }: Props = $props();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="banner" role="note">
|
||||
<span class="icon"><Icon name="warning" /></span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
+16
-21
@@ -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<ReleaseScope, number>;
|
||||
// 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<string>,
|
||||
@@ -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<T>(
|
||||
items: T[],
|
||||
did: (item: T) => string,
|
||||
@@ -96,9 +87,6 @@ export function delegatesFirst<T>(
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string>,
|
||||
@@ -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<string>,
|
||||
artifactScope: ReleaseScope,
|
||||
): "release" | "artifacts" | undefined {
|
||||
if (!delegates.has(creatorDid)) {
|
||||
return "release";
|
||||
}
|
||||
return artifactScope === "untrusted" ? "artifacts" : undefined;
|
||||
}
|
||||
|
||||
+34
-1279
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
import ReleaseTeaser from "@app/components/ReleaseTeaser.svelte";
|
||||
import ScrollArea from "@app/components/ScrollArea.svelte";
|
||||
import Topbar from "@app/components/Topbar.svelte";
|
||||
import UntrustedWarning from "@app/components/UntrustedWarning.svelte";
|
||||
import VirtualList from "@app/components/VirtualList.svelte";
|
||||
import CreateReleaseModal from "@app/modals/CreateRelease.svelte";
|
||||
|
||||
@@ -85,6 +86,10 @@
|
||||
.filter .global-counter-badge {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.warning {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.row {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
@@ -158,6 +163,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if scope === "untrusted"}
|
||||
<div class="warning">
|
||||
<UntrustedWarning
|
||||
text="Not from delegates. Only download from authors you trust." />
|
||||
</div>
|
||||
{/if}
|
||||
<ScrollArea style="height: 100%; min-width: 0;">
|
||||
<VirtualList
|
||||
items={list.items}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
useBackend,
|
||||
} from "@tests/support/fixtures.js";
|
||||
|
||||
const OTHER_DID = "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5";
|
||||
|
||||
test("the commit picker lists commits and rejects unknown SHAs", async ({
|
||||
page,
|
||||
peer,
|
||||
@@ -222,9 +224,7 @@ test("a location added by someone else cannot be removed", async ({
|
||||
const release = await (await route.fetch()).json();
|
||||
for (const artifact of release.artifacts) {
|
||||
artifact.locations.push({
|
||||
user: {
|
||||
did: "did:key:z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5",
|
||||
},
|
||||
user: { did: OTHER_DID },
|
||||
url,
|
||||
});
|
||||
}
|
||||
@@ -270,6 +270,83 @@ test("removing your node's location while seeding stops seeding", async ({
|
||||
expect((await unseed).postDataJSON()).toMatchObject({ releaseId, cid });
|
||||
});
|
||||
|
||||
test("a release by a non-delegate shows a warning", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
await expect(page.getByRole("note")).toBeHidden();
|
||||
|
||||
await page.route(/\/release_by_id$/, async route => {
|
||||
const release = await (await route.fetch()).json();
|
||||
release.creator.did = OTHER_DID;
|
||||
await route.fulfill({ json: release });
|
||||
});
|
||||
await reload(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("note").getByText("Not from a delegate."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("an artifact by a non-delegate shows a warning", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
// A location makes the download button usable.
|
||||
await page.route(/\/release_by_id$/, async route => {
|
||||
const release = await (await route.fetch()).json();
|
||||
for (const artifact of release.artifacts) {
|
||||
artifact.author.did = OTHER_DID;
|
||||
artifact.locations.push({
|
||||
user: { did: OTHER_DID },
|
||||
url: "https://example.com/theirs.tar",
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: release });
|
||||
});
|
||||
await reload(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("note").getByText("Not from delegates."),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "icon-download Download" }).click();
|
||||
await expect(page.getByText("Not from a delegate.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a link by a non-delegate on a delegate's artifact shows a warning", async ({
|
||||
page,
|
||||
peer,
|
||||
}) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/repos/${rid}/releases/\\w+`));
|
||||
|
||||
await page.route(/\/release_by_id$/, async route => {
|
||||
const release = await (await route.fetch()).json();
|
||||
for (const artifact of release.artifacts) {
|
||||
artifact.locations.push({
|
||||
user: { did: OTHER_DID },
|
||||
url: "https://example.com/theirs.tar",
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: release });
|
||||
});
|
||||
await reload(page);
|
||||
|
||||
await page.getByRole("button", { name: "icon-download Download" }).click();
|
||||
await expect(page.getByText("Not from a delegate.")).toBeHidden();
|
||||
await page
|
||||
.getByRole("button", { name: "icon-open-external Browser" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("note").getByText("Links without a delegate badge"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("metadata can be added, edited and removed", async ({ page, peer }) => {
|
||||
const rid = await fillRelease(page, peer);
|
||||
await page.getByRole("button", { name: "Create release" }).click();
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseMetadataValue,
|
||||
redactedByDelegate,
|
||||
releaseListScope,
|
||||
releaseWarning,
|
||||
webLocations,
|
||||
} from "@app/lib/releases";
|
||||
|
||||
@@ -307,3 +308,18 @@ describe("canonicalTags", () => {
|
||||
).toBe(canonical);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseWarning", () => {
|
||||
test("warns about a whole release by a non-delegate", () => {
|
||||
expect(releaseWarning(them, delegates, "trusted")).toBe("release");
|
||||
expect(releaseWarning(them, delegates, "untrusted")).toBe("release");
|
||||
});
|
||||
|
||||
test("warns about others' artifacts on a delegate's release", () => {
|
||||
expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts");
|
||||
});
|
||||
|
||||
test("does not warn about a delegate's release and artifacts", () => {
|
||||
expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user