Skip to content

Commit 147b1f1

Browse files
rubenvdlindeConduction Release Bot
andauthored
feat(external-sources): auto-pin SHA-256 on external install (TOFU) (#95)
Records the observed SHA-256 of every externally-installed artifact in the app's source binding on first successful install, and enforces it on any later download of the same app+version from the same source. A rewritten GitHub/Codeberg release (including a co-published, rewritten .sha256 sibling) now fails the install closed instead of being silently absorbed. - SourceBinding gains a capped (200-entry, oldest-evicted) sha256 map with typed accessors, round-tripping through toArray()/fromArray() - ExternalReleaseInstallerService enforces the recorded digest before extraction/backup (no filesystem change on mismatch), records on success only, and supports a single-request acceptNewSha override that is warning-logged and folded into the existing install audit entry - SourceBindingStore preserves recorded digests on a same-source rebind and discards them on a different-source rebind (single write path for both the bind endpoint and the implicit install-time bind) - API: binding read + version list expose recorded digests; install responses carry a sha_mismatch machine-readable code (422) with expected/ actual digests, and a recordedShaMatched flag on success - Frontend: version picker badges versions with a recorded digest; a new ShaMismatchDialog offers the explicit "Accept new checksum and install" escape hatch through the normal password-confirmed install flow Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
1 parent 08e68e5 commit 147b1f1

18 files changed

Lines changed: 1086 additions & 46 deletions

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<description>> ⚠️ **Active development — not for production use yet.** This app is under active development. Although it may carry a stable release status, **please do not use it in production environments before 12 June 2026.** See [conduction.nl/apps](https://conduction.nl/apps) for release planning and what this app does.
88

99
App Versions gives Nextcloud administrators the ability to roll back apps to previous versions or install specific newer versions. Essential for debugging, testing compatibility, and recovering from broken updates.</description>
10-
<version>1.2.0</version>
10+
<version>1.3.0</version>
1111
<licence>EUPL-1.2</licence>
1212
<author mail="info@conduction.nl" homepage="https://conduction.nl">Conduction B.V.</author>
1313
<namespace>AppVersions</namespace>

l10n/nl.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@
1818
"{appId} is pinned to {pinnedVersion} but is now running {observedVersion} — something other than App Versions changed it.": "{appId} is vastgezet op {pinnedVersion}, maar draait nu {observedVersion} — iets anders dan App Versions heeft dit gewijzigd.",
1919
"Re-pin {version}": "Opnieuw vastzetten op {version}",
2020
"Accept: move pin to {version}": "Accepteren: vastzetting verplaatsen naar {version}",
21-
"Accept: remove pin": "Accepteren: vastzetting verwijderen"
21+
"Accept: remove pin": "Accepteren: vastzetting verwijderen",
22+
"Checksum does not match first install": "Checksum komt niet overeen met de eerste installatie",
23+
"Accept new checksum and install": "Nieuwe checksum accepteren en installeren",
24+
"{appId} {version} was previously installed with a different SHA-256 checksum. The upstream release may have been rewritten since — App Versions blocks the install to protect you from a silently altered artifact.": "{appId} {version} is eerder geïnstalleerd met een andere SHA-256-checksum. De release bij de bron is mogelijk sindsdien herschreven — App Versions blokkeert de installatie om je te beschermen tegen een stilzwijgend gewijzigd bestand.",
25+
"Recorded at first install": "Vastgelegd bij eerste installatie",
26+
"Just downloaded": "Zojuist gedownload",
27+
"Only accept if you are certain this change is legitimate — for example the maintainer re-tagged the release to fix a packaging error.": "Accepteer dit alleen als je zeker weet dat deze wijziging legitiem is — bijvoorbeeld wanneer de maintainer de release opnieuw heeft getagd om een verpakkingsfout te herstellen.",
28+
"Matches first-install checksum": "Komt overeen met checksum van eerste installatie",
29+
"Checksum recorded": "Checksum vastgelegd"
2230
},
2331
"pluralForm": "nplurals=2; plural=(n != 1);"
2432
}

lib/Controller/ApiController.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ public function sources(): DataResponse {
141141
}
142142

143143
/**
144-
* Returns the active source binding for an app; see "Source binding".
144+
* Returns the active source binding for an app, including any recorded
145+
* SHA-256 digests (not secrets); see "Source binding" and "Recorded
146+
* digests are binding-scoped and surfaced".
145147
*
146148
* @spec openspec/specs/external-sources/spec.md
147149
*/
@@ -200,10 +202,16 @@ public function bindSource(string $appId): DataResponse {
200202
return new DataResponse(['message' => $error->getMessage()], Http::STATUS_FORBIDDEN);
201203
}
202204

205+
// Re-read the persisted binding: rebinding to the same source id
206+
// preserves any previously recorded SHA-256 digests, so the response
207+
// should reflect what was actually written, not the pre-write value —
208+
// see "Recorded digests are binding-scoped and surfaced".
209+
$persisted = $this->installerService->getBinding($appId);
210+
203211
return new DataResponse([
204212
'appId' => $appId,
205213
'sourceId' => $binding->getId(),
206-
'binding' => $binding->toArray(),
214+
'binding' => ($persisted ?? $binding)->toArray(),
207215
]);
208216
}
209217

@@ -293,10 +301,14 @@ public function appVersions(string $appId): DataResponse {
293301
/**
294302
* Installs a specific version (password-confirmed); see "Install Specific
295303
* Version" and, when the app is pinned, "Pins are enforced on App
296-
* Versions' own install path" (`overridePin=repin|unpin`, `pin=1`).
304+
* Versions' own install path" (`overridePin=repin|unpin`, `pin=1`). For an
305+
* external source with a recorded SHA-256 mismatch, `acceptNewSha=1`
306+
* bypasses the check once and replaces the recorded digest on success; see
307+
* "Recorded SHA-256 enforced on reinstall".
297308
*
298309
* @spec openspec/specs/version-management/spec.md
299310
* @spec openspec/specs/version-pinning/spec.md
311+
* @spec openspec/specs/external-sources/spec.md
300312
*/
301313
#[PasswordConfirmationRequired(strict: false)]
302314
#[ApiRoute(verb: 'POST', url: '/api/app/{appId}/versions/{version}/install')]
@@ -322,6 +334,7 @@ public function installVersion(string $appId, string $version): DataResponse {
322334
$overridePinRaw = $this->stringParam('overridePin', '');
323335
$overridePin = $overridePinRaw === '' ? null : $overridePinRaw;
324336
$pinRequested = $this->readBinaryBool($this->request->getParam('pin', '0'), false);
337+
$acceptNewSha = $this->readBinaryBool($this->request->getParam('acceptNewSha', '0'), false);
325338

326339
$result = $this->installerService->installAppVersion(
327340
$appId,
@@ -330,6 +343,7 @@ public function installVersion(string $appId, string $version): DataResponse {
330343
$sourceOverride,
331344
$overridePin,
332345
$pinRequested,
346+
$acceptNewSha,
333347
);
334348
$result['payload']['requestedVersion'] = $requestedVersion;
335349
$result['payload']['routeVersion'] = $version;

lib/Service/ExternalReleaseInstallerService.php

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OCA\AppVersions\Service\Installer\FailureClassifier;
2121
use OCA\AppVersions\Service\Installer\InstallFailure;
2222
use OCA\AppVersions\Service\Installer\InstallFinalizer;
23+
use OCA\AppVersions\Service\Installer\ShaMismatchException;
2324
use OCA\AppVersions\Service\Pat\PatManager;
2425
use OCA\AppVersions\Service\Pat\PatResolver;
2526
use OCA\AppVersions\Service\Source\SourceBinding;
@@ -42,9 +43,14 @@
4243
* Nextcloud-issued code-signing certificate to verify and no per-release
4344
* signature to check. To compensate we apply:
4445
* 1. Trusted-source allowlist gate (no download until passed)
45-
* 2. Optional SHA-256 verification when the release publishes a sibling .sha256 asset
46-
* 3. Mandatory appId match against the extracted `appinfo/info.xml`
47-
* 4. Mandatory version match against the extracted `appinfo/info.xml`
46+
* 2. Trust-on-first-use: the downloaded archive's SHA-256 MUST match the
47+
* digest recorded on the binding from a previous successful install of
48+
* the same (appId, version, source), if one is recorded — before
49+
* extraction/backup, `acceptNewSha` bypasses once and replaces on
50+
* success; see "Recorded SHA-256 enforced on reinstall"
51+
* 3. Optional SHA-256 verification when the release publishes a sibling .sha256 asset
52+
* 4. Mandatory appId match against the extracted `appinfo/info.xml`
53+
* 5. Mandatory version match against the extracted `appinfo/info.xml`
4854
*
4955
* The post-extract finalization (migrations, repair steps, config writes) is
5056
* delegated to `InstallFinalizer` so signed and external installs cannot drift
@@ -80,20 +86,27 @@ public function getDebugLog(): array {
8086
}
8187

8288
/**
83-
* Downloads, integrity-checks (allowlist, SHA-256, appId/version), and installs an external release;
84-
* see "External install integrity checks".
89+
* Downloads, integrity-checks (allowlist, recorded SHA-256, sibling SHA-256,
90+
* appId/version), and installs an external release; see "External install
91+
* integrity checks", "SHA-256 recorded on first successful external
92+
* install", and "Recorded SHA-256 enforced on reinstall".
8593
*
8694
* @spec openspec/specs/external-sources/spec.md
8795
* @param array<string, mixed> $release
88-
* @return array{status: string, installedVersionBefore: ?string, installedApp?: string, integrityWarning?: ?string, dryRun: bool, debug: list<array{stage: string, data: mixed}>}
96+
* @param bool $acceptNewSha Single-request bypass of the recorded-SHA-256
97+
* check; on success the recorded digest is replaced (password-confirmed
98+
* at the API layer, warning-logged and audited here).
99+
* @return array{status: string, installedVersionBefore: ?string, installedApp?: string, integrityWarning?: ?string, dryRun: bool, debug: list<array{stage: string, data: mixed}>, binding: SourceBinding, recordedShaMatched: bool}
89100
* @throws Exception
101+
* @throws ShaMismatchException
90102
*/
91103
public function installFromExternalRelease(
92104
string $appId,
93105
string $version,
94106
array $release,
95107
SourceBinding $binding,
96108
bool $dryRun = false,
109+
bool $acceptNewSha = false,
97110
): array {
98111
$this->resetDebug();
99112
$this->addDebug('requested-install', [
@@ -142,7 +155,34 @@ public function installFromExternalRelease(
142155
}
143156
$this->addDebug('downloaded', ['tempFile' => $tempFile, 'sourceUrl' => $downloadUrl]);
144157

145-
$integrityWarning = $this->verifyChecksum($tempFile, $shaUrl, $authResolution);
158+
// Hash the downloaded archive exactly once; both the recorded-digest
159+
// (TOFU) check below and the sibling `.sha256` verification reuse
160+
// this value — see design.md "Enforcement point".
161+
$actualSha = hash_file('sha256', $tempFile);
162+
if ($actualSha === false) {
163+
throw new Exception('Could not compute SHA-256 of downloaded archive.');
164+
}
165+
$actualSha = strtolower($actualSha);
166+
167+
// Trust-on-first-use enforcement: a digest recorded from a previous
168+
// successful install of this (appId, version, source) outranks
169+
// whatever the source serves now, including a co-published,
170+
// possibly-rewritten `.sha256` sibling — see "Recorded SHA-256
171+
// enforced on reinstall". Checked before extraction/backup: no
172+
// filesystem change happens on mismatch.
173+
$recordedSha = $binding->getRecordedSha($version);
174+
$recordedShaMatched = $recordedSha !== null && hash_equals($recordedSha, $actualSha);
175+
if ($recordedSha !== null && !$acceptNewSha && !$recordedShaMatched) {
176+
throw new ShaMismatchException($appId, $version, $recordedSha, $actualSha);
177+
}
178+
$this->addDebug('recorded-sha-check', [
179+
'recordedSha' => $recordedSha,
180+
'actualSha' => $actualSha,
181+
'matched' => $recordedShaMatched,
182+
'acceptNewSha' => $acceptNewSha,
183+
]);
184+
185+
$integrityWarning = $this->verifyChecksum($actualSha, $shaUrl, $authResolution);
146186
$this->addDebug('checksum', ['shaUrl' => $shaUrl, 'integrityWarning' => $integrityWarning]);
147187

148188
$archivePath = $this->extractArchive($tempFile, $tempFolder);
@@ -175,6 +215,8 @@ public function installFromExternalRelease(
175215
'integrityWarning' => $integrityWarning,
176216
'dryRun' => true,
177217
'debug' => $this->debug,
218+
'binding' => $binding,
219+
'recordedShaMatched' => $recordedShaMatched,
178220
];
179221
}
180222

@@ -234,7 +276,32 @@ public function installFromExternalRelease(
234276
}
235277
$this->addDebug('finalized', ['appId' => $installedApp, 'enabled' => $enabled]);
236278

237-
$this->recordInstallAudit($appId, $binding, $installedVersion, $version, AuditLogger::STATUS_SUCCESS, $integrityWarning);
279+
// Record the observed digest only now that the install fully
280+
// succeeded (never on a failure path) — see "SHA-256 recorded on
281+
// first successful external install". Sibling-verified or locally
282+
// computed, the value is the same: verifyChecksum() above already
283+
// threw if a sibling digest existed and disagreed with $actualSha.
284+
$updatedBinding = $binding->withRecordedSha($version, $actualSha);
285+
$shaAccepted = $recordedSha !== null && !$recordedShaMatched;
286+
$auditMessage = $integrityWarning;
287+
if ($shaAccepted) {
288+
$this->logger->warning('ExternalReleaseInstallerService: accepted a new SHA-256 for a previously recorded version (acceptNewSha override)', [
289+
'appId' => $appId,
290+
'version' => $version,
291+
'previousSha' => $recordedSha,
292+
'newSha' => $actualSha,
293+
]);
294+
$shaAcceptNote = sprintf(
295+
'SHA-256 override accepted for %s@%s: previous %s, new %s.',
296+
$appId,
297+
$version,
298+
$recordedSha,
299+
$actualSha,
300+
);
301+
$auditMessage = $auditMessage !== null ? $auditMessage . ' ' . $shaAcceptNote : $shaAcceptNote;
302+
}
303+
304+
$this->recordInstallAudit($appId, $binding, $installedVersion, $version, AuditLogger::STATUS_SUCCESS, $auditMessage);
238305

239306
return [
240307
'status' => 'installed',
@@ -243,6 +310,8 @@ public function installFromExternalRelease(
243310
'integrityWarning' => $integrityWarning,
244311
'dryRun' => false,
245312
'debug' => $this->debug,
313+
'binding' => $updatedBinding,
314+
'recordedShaMatched' => $recordedShaMatched,
246315
];
247316
} catch (\Throwable $error) {
248317
// Best-effort audit write on the failure path, before the exception
@@ -322,7 +391,13 @@ private function authenticatedDownload(string $url, string $sinkPath, ?\OCA\AppV
322391
});
323392
}
324393

325-
private function verifyChecksum(string $tempFile, ?string $shaUrl, ?\OCA\AppVersions\Db\Pat $pat): ?string {
394+
/**
395+
* Verifies the already-computed `$actualSha` (downloaded archive) against
396+
* the release's sibling `.sha256` asset when one exists. This is a
397+
* transport check only — see design.md "Trust model: TOFU" for why the
398+
* recorded-digest check (above, in the caller) takes precedence.
399+
*/
400+
private function verifyChecksum(string $actualSha, ?string $shaUrl, ?\OCA\AppVersions\Db\Pat $pat): ?string {
326401
if ($shaUrl === null) {
327402
return 'No SHA-256 checksum available for this artifact.';
328403
}
@@ -364,16 +439,11 @@ private function verifyChecksum(string $tempFile, ?string $shaUrl, ?\OCA\AppVers
364439
return 'SHA-256 file format unrecognized; install proceeded without verification.';
365440
}
366441

367-
$actual = hash_file('sha256', $tempFile);
368-
if ($actual === false) {
369-
return 'Could not compute SHA-256 of downloaded archive.';
370-
}
371-
372-
if (!hash_equals(strtolower($expected), strtolower($actual))) {
442+
if (!hash_equals(strtolower($expected), $actualSha)) {
373443
throw new Exception(sprintf(
374444
'SHA-256 mismatch — expected %s, got %s.',
375445
strtolower($expected),
376-
strtolower($actual)
446+
$actualSha
377447
));
378448
}
379449

lib/Service/Installer/FailureClassifier.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ class FailureClassifier {
3434
public const CATEGORY_PREFLIGHT_PERMISSION = 'preflight_permission';
3535
public const CATEGORY_DOWNLOAD = 'download';
3636
public const CATEGORY_CHECKSUM_MISMATCH = 'checksum_mismatch';
37+
/**
38+
* A recorded (trust-on-first-use) SHA-256 mismatch — distinct from
39+
* {@see CATEGORY_CHECKSUM_MISMATCH} (the source-published sibling
40+
* checksum): this is a history check the source cannot rewrite. See
41+
* "Recorded SHA-256 enforced on reinstall".
42+
*/
43+
public const CATEGORY_SHA_MISMATCH = 'sha_mismatch';
3744
public const CATEGORY_EXTRACT = 'extract';
3845
public const CATEGORY_APPID_MISMATCH = 'appid_mismatch';
3946
public const CATEGORY_VERSION_MISMATCH = 'version_mismatch';
@@ -88,7 +95,8 @@ public function httpStatusFor(string $category): int {
8895
self::CATEGORY_INCOMPATIBLE,
8996
self::CATEGORY_VERSION_MISMATCH,
9097
self::CATEGORY_APPID_MISMATCH,
91-
self::CATEGORY_CHECKSUM_MISMATCH => Http::STATUS_UNPROCESSABLE_ENTITY,
98+
self::CATEGORY_CHECKSUM_MISMATCH,
99+
self::CATEGORY_SHA_MISMATCH => Http::STATUS_UNPROCESSABLE_ENTITY,
92100
self::CATEGORY_DOWNLOAD => Http::STATUS_BAD_GATEWAY,
93101
default => Http::STATUS_INTERNAL_SERVER_ERROR,
94102
};
@@ -152,6 +160,7 @@ public function hintFor(string $category): string {
152160
self::CATEGORY_PREFLIGHT_PERMISSION => $l->t('The app folder is not writable by the web-server user. If this is a bind-mounted dev checkout, fix the folder ownership/permissions (or install the app into a writable apps directory).'),
153161
self::CATEGORY_DOWNLOAD => $l->t('The release could not be downloaded from its source. Check connectivity to the source and that the release asset still exists.'),
154162
self::CATEGORY_CHECKSUM_MISMATCH => $l->t('The downloaded archive failed its integrity check. The release may be corrupted or tampered with; do not install it.'),
163+
self::CATEGORY_SHA_MISMATCH => $l->t('The downloaded artifact does not match the SHA-256 recorded the first time this version was installed. The upstream release may have been rewritten. Only proceed if you are certain the new artifact is legitimate, then explicitly accept the new checksum to install it.'),
155164
self::CATEGORY_EXTRACT => $l->t('The release archive could not be extracted. The downloaded file may be incomplete or not a valid app archive.'),
156165
self::CATEGORY_APPID_MISMATCH => $l->t('The downloaded archive is for a different app than requested. Verify the source binding points at the correct repository.'),
157166
self::CATEGORY_VERSION_MISMATCH => $l->t('The downloaded archive declares a different version than requested. The source metadata and asset may be out of sync.'),
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* @license EUPL-1.2
6+
* @copyright Copyright (c) 2025, Conduction B.V. <info@conduction.nl>
7+
*
8+
* SPDX-FileCopyrightText: 2025 Conduction B.V. <info@conduction.nl>
9+
* SPDX-License-Identifier: EUPL-1.2
10+
*/
11+
12+
13+
namespace OCA\AppVersions\Service\Installer;
14+
15+
use Exception;
16+
17+
/**
18+
* Thrown by {@see \OCA\AppVersions\Service\ExternalReleaseInstallerService}
19+
* when the SHA-256 of a freshly downloaded artifact does not match the digest
20+
* recorded on the binding at a previous successful install of the same
21+
* (appId, version, source) triple. Thrown before extraction/backup — no
22+
* filesystem change has happened when this is raised. The HTTP layer maps
23+
* this to the machine-readable `sha_mismatch` error code; see "Recorded
24+
* SHA-256 enforced on reinstall".
25+
*
26+
* @spec openspec/specs/external-sources/spec.md
27+
* @psalm-api
28+
*/
29+
final class ShaMismatchException extends Exception {
30+
public function __construct(
31+
public readonly string $appId,
32+
public readonly string $version,
33+
public readonly string $expectedSha,
34+
public readonly string $actualSha,
35+
) {
36+
parent::__construct(sprintf(
37+
'Artifact for %s@%s does not match the checksum recorded at first install (expected %s, got %s). The release may have been rewritten upstream.',
38+
$appId,
39+
$version,
40+
$expectedSha,
41+
$actualSha,
42+
));
43+
}
44+
}

0 commit comments

Comments
 (0)