Discover signed runtime artifacts from public repository fallback
This commit is contained in:
+24
-3
@@ -3,7 +3,11 @@
|
|||||||
The new ISO includes an independent updater and an independently pinned Ed25519
|
The new ISO includes an independent updater and an independently pinned Ed25519
|
||||||
public key. Discovery uses the public Gitea repository at
|
public key. Discovery uses the public Gitea repository at
|
||||||
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source.
|
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS`; discovery is not a trust source.
|
||||||
An empty releases list is normal and reports no update.
|
If Gitea has no published runtime release candidate, discovery checks the fixed
|
||||||
|
public repository path `releases/stable/` on branch `main`. If that path has no
|
||||||
|
manifest (HTTP 404), it reports no update. Both locations use the same independently
|
||||||
|
pinned signature key; Git branch contents and repository ownership do not establish
|
||||||
|
release trust.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hydra update check
|
hydra update check
|
||||||
@@ -82,7 +86,16 @@ python3 dev/release.py \
|
|||||||
|
|
||||||
The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`.
|
The three release assets are `manifest.json`, `manifest.sig`, and `runtime.zip`.
|
||||||
Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed
|
Publish them under the immutable Gitea tag `1.0.0`, exactly matching the signed
|
||||||
version. Each new release needs a strictly increasing integer sequence; never
|
version. Alternatively, when the native Gitea release list has no runtime candidates,
|
||||||
|
publish those same three passive artifacts together in `releases/stable/` on the
|
||||||
|
repository's `main` branch. The raw HTTPS prefix is fixed:
|
||||||
|
`https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/`.
|
||||||
|
The branch serves as a discovery location; the verified manifest supplies its
|
||||||
|
version and sequence. Keep historical signed artifacts under immutable versions
|
||||||
|
outside this moving stable pointer. Native Gitea runtime releases take precedence;
|
||||||
|
a present native candidate, including one already installed, disables this fallback.
|
||||||
|
|
||||||
|
Each new release needs a strictly increasing integer sequence; never
|
||||||
reuse a sequence or mutate assets at an existing version. Rollback retains the
|
reuse a sequence or mutate assets at an existing version. Rollback retains the
|
||||||
highest accepted sequence independently of the current installed version.
|
highest accepted sequence independently of the current installed version.
|
||||||
The builder is deterministic for identical source, version, sequence, key and
|
The builder is deterministic for identical source, version, sequence, key and
|
||||||
@@ -105,7 +118,15 @@ The maximum manifest is 256 KiB, archive/expanded payload 16 MiB, individual fil
|
|||||||
per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery
|
per-artifact deadline; redirects remain on the fixed HTTPS origin. Discovery
|
||||||
checks up to the latest 20 published release records, skipping draft/prerelease
|
checks up to the latest 20 published release records, skipping draft/prerelease
|
||||||
records. Publish runtime releases regularly enough that the current stable release
|
records. Publish runtime releases regularly enough that the current stable release
|
||||||
remains in that window.
|
remains in that window. Only an otherwise successful discovery with no runtime
|
||||||
|
candidates activates the repository fallback. API errors, malformed native assets,
|
||||||
|
tag mismatches and bad signatures remain visible errors and never trigger fallback.
|
||||||
|
For the repository path, only a missing `manifest.json` (404) means no release;
|
||||||
|
a missing signature, server error or invalid signature is an error. The ZIP is
|
||||||
|
fetched only for apply after manifest verification, and its signed hash/size and
|
||||||
|
all existing download bounds/redirect restrictions still apply. A branch update
|
||||||
|
between requests may cause a safe verification failure; retry after publication
|
||||||
|
finishes.
|
||||||
|
|
||||||
For offline/local verification and application:
|
For offline/local verification and application:
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -15,6 +15,7 @@ import stat
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
@@ -26,6 +27,7 @@ FILE_LIMIT = 4 * 1024 * 1024
|
|||||||
MAX_FILES = 256
|
MAX_FILES = 256
|
||||||
ORIGIN = 'https://git.shanaty.ru'
|
ORIGIN = 'https://git.shanaty.ru'
|
||||||
RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20'
|
RELEASES = ORIGIN + '/api/v1/repos/Emil_Shanaty/Hydra-OS/releases?limit=20'
|
||||||
|
REPOSITORY_RELEASE = ORIGIN + '/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||||
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc',
|
EXACT = {'usr/lib/hydra/bin/hydra', 'etc/motd', 'etc/fastfetch/config.jsonc',
|
||||||
'usr/share/hydra/fastfetch.txt'}
|
'usr/share/hydra/fastfetch.txt'}
|
||||||
|
|
||||||
@@ -516,6 +518,21 @@ def fetch(url, limit):
|
|||||||
return b''.join(chunks)
|
return b''.join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_repository(public_key):
|
||||||
|
# The mutable branch locates passive bytes; only the pinned signature grants trust.
|
||||||
|
try:
|
||||||
|
raw = fetch(REPOSITORY_RELEASE + 'manifest.json', MANIFEST_LIMIT)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code != 404:
|
||||||
|
raise
|
||||||
|
exc.close()
|
||||||
|
return None
|
||||||
|
# A present manifest with missing/broken companions is an error, never "no release".
|
||||||
|
signature = fetch(REPOSITORY_RELEASE + 'manifest.sig', 64)
|
||||||
|
manifest = validate_manifest(raw, signature, public_key)
|
||||||
|
return manifest, raw, signature, REPOSITORY_RELEASE + 'runtime.zip'
|
||||||
|
|
||||||
|
|
||||||
def discover(public_key):
|
def discover(public_key):
|
||||||
releases = parse_json(fetch(RELEASES, 1024 * 1024))
|
releases = parse_json(fetch(RELEASES, 1024 * 1024))
|
||||||
if not isinstance(releases, list) or len(releases) > 20:
|
if not isinstance(releases, list) or len(releases) > 20:
|
||||||
@@ -549,7 +566,7 @@ def discover(public_key):
|
|||||||
raise UpdateError('Release tag does not match signed immutable version')
|
raise UpdateError('Release tag does not match signed immutable version')
|
||||||
candidates.append((manifest, raw, signature, found['runtime.zip']))
|
candidates.append((manifest, raw, signature, found['runtime.zip']))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return discover_repository(public_key)
|
||||||
sequences = [item[0]['sequence'] for item in candidates]
|
sequences = [item[0]['sequence'] for item in candidates]
|
||||||
if len(sequences) != len(set(sequences)):
|
if len(sequences) != len(set(sequences)):
|
||||||
raise UpdateError('Duplicate release sequence')
|
raise UpdateError('Duplicate release sequence')
|
||||||
|
|||||||
+112
-1
@@ -250,7 +250,14 @@ CALL
|
|||||||
def test_empty_releases_and_verified_local_check_do_not_create_state(self):
|
def test_empty_releases_and_verified_local_check_do_not_create_state(self):
|
||||||
module = engine()
|
module = engine()
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
with patch.object(module, 'fetch', return_value=b'[]'):
|
from urllib.error import HTTPError
|
||||||
|
def empty_releases(url, limit):
|
||||||
|
if url == module.RELEASES:
|
||||||
|
return b'[]'
|
||||||
|
if url == 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/manifest.json':
|
||||||
|
raise HTTPError(url, 404, 'Not Found', {}, None)
|
||||||
|
raise AssertionError('Unexpected discovery URL: ' + url)
|
||||||
|
with patch.object(module, 'fetch', side_effect=empty_releases):
|
||||||
self.assertIsNone(module.discover(self.pub))
|
self.assertIsNone(module.discover(self.pub))
|
||||||
self.package()
|
self.package()
|
||||||
result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root),
|
result = subprocess.run([sys.executable, '-I', str(ENGINE), 'check', '--root', str(self.root),
|
||||||
@@ -346,6 +353,7 @@ CALL
|
|||||||
with patch.object(module, 'fetch', side_effect=fetch):
|
with patch.object(module, 'fetch', side_effect=fetch):
|
||||||
found = module.discover(self.pub)
|
found = module.discover(self.pub)
|
||||||
self.assertEqual(found[0]['version'], '1.2.3')
|
self.assertEqual(found[0]['version'], '1.2.3')
|
||||||
|
self.assertFalse(any('/raw/branch/' in url for url in requested))
|
||||||
self.assertNotIn(urls['runtime.zip'], requested)
|
self.assertNotIn(urls['runtime.zip'], requested)
|
||||||
release['tag_name'] = 'latest'
|
release['tag_name'] = 'latest'
|
||||||
with self.assertRaisesRegex(ValueError, 'tag'):
|
with self.assertRaisesRegex(ValueError, 'tag'):
|
||||||
@@ -462,3 +470,106 @@ CALL
|
|||||||
self.assertEqual(updater.status(), authoritative)
|
self.assertEqual(updater.status(), authoritative)
|
||||||
finally:
|
finally:
|
||||||
updater.base.chmod(0o700)
|
updater.base.chmod(0o700)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_repository_fallback_applies_and_preserves_replay_protection(self):
|
||||||
|
self.package()
|
||||||
|
module = engine()
|
||||||
|
from unittest.mock import patch
|
||||||
|
from contextlib import redirect_stdout, redirect_stderr
|
||||||
|
import io
|
||||||
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||||
|
requested = []
|
||||||
|
def fetch(url, limit):
|
||||||
|
requested.append(url)
|
||||||
|
if url == module.RELEASES:
|
||||||
|
return b'[]'
|
||||||
|
if url not in {base + name for name in ('manifest.json', 'manifest.sig', 'runtime.zip')}:
|
||||||
|
raise AssertionError('Unexpected fallback URL: ' + url)
|
||||||
|
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||||
|
with patch.object(module, 'fetch', side_effect=fetch):
|
||||||
|
found = module.discover(self.pub)
|
||||||
|
self.assertIsNotNone(found)
|
||||||
|
self.assertEqual(found[0]['version'], '1.2.3')
|
||||||
|
self.assertEqual(found[3], base + 'runtime.zip')
|
||||||
|
self.assertNotIn(base + 'runtime.zip', requested)
|
||||||
|
with redirect_stdout(io.StringIO()) as output:
|
||||||
|
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(json.loads(output.getvalue())['applied'])
|
||||||
|
self.assertEqual(self.target.read_bytes(), b'new runtime\n')
|
||||||
|
with redirect_stderr(io.StringIO()) as error:
|
||||||
|
code = module.main(['apply', '--root', str(self.root), '--public-key', str(self.pub)])
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertIn('replay', error.getvalue())
|
||||||
|
self.assertEqual(self.vm.read_bytes(), b'guest disk unchanged')
|
||||||
|
|
||||||
|
def test_repository_fallback_refuses_tamper_missing_signature_and_server_errors(self):
|
||||||
|
self.package()
|
||||||
|
module = engine()
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||||
|
for failure in ('signature', 'missing_signature', 'server_error'):
|
||||||
|
with self.subTest(failure=failure):
|
||||||
|
requested = []
|
||||||
|
def fetch(url, limit):
|
||||||
|
requested.append(url)
|
||||||
|
if url == module.RELEASES:
|
||||||
|
return b'[]'
|
||||||
|
if url == base + 'manifest.json':
|
||||||
|
if failure == 'server_error':
|
||||||
|
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
||||||
|
return (self.artifacts / 'manifest.json').read_bytes()
|
||||||
|
if url == base + 'manifest.sig':
|
||||||
|
if failure == 'missing_signature':
|
||||||
|
raise HTTPError(url, 404, 'Not Found', {}, None)
|
||||||
|
return b'x' * 64
|
||||||
|
raise AssertionError('Unsigned archive must not be fetched')
|
||||||
|
with patch.object(module, 'fetch', side_effect=fetch):
|
||||||
|
with self.assertRaises((ValueError, HTTPError)):
|
||||||
|
module.discover(self.pub)
|
||||||
|
self.assertNotIn(base + 'runtime.zip', requested)
|
||||||
|
self.assertFalse((self.root / 'var/lib/hydra-update').exists())
|
||||||
|
|
||||||
|
def test_native_release_errors_do_not_fall_back_to_repository_assets(self):
|
||||||
|
self.package()
|
||||||
|
module = engine()
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
assets = [{'name': name, 'browser_download_url': 'https://git.shanaty.ru/attachments/' + name}
|
||||||
|
for name in ('manifest.json', 'manifest.sig', 'runtime.zip')]
|
||||||
|
for failure in ('tag', 'incomplete', 'signature', 'api_unavailable'):
|
||||||
|
with self.subTest(failure=failure):
|
||||||
|
def fetch(url, limit):
|
||||||
|
if '/raw/branch/' in url:
|
||||||
|
raise AssertionError('API failures must not activate fallback')
|
||||||
|
if url == module.RELEASES:
|
||||||
|
if failure == 'api_unavailable':
|
||||||
|
raise HTTPError(url, 503, 'Unavailable', {}, None)
|
||||||
|
return json.dumps([{'tag_name': 'wrong' if failure == 'tag' else '1.2.3',
|
||||||
|
'assets': assets[:1] if failure == 'incomplete' else assets}]).encode()
|
||||||
|
if failure == 'signature' and url.endswith('manifest.sig'):
|
||||||
|
return b'x' * 64
|
||||||
|
return (self.artifacts / url.rsplit('/', 1)[1]).read_bytes()
|
||||||
|
with patch.object(module, 'fetch', side_effect=fetch):
|
||||||
|
with self.assertRaises((ValueError, HTTPError)):
|
||||||
|
module.discover(self.pub)
|
||||||
|
|
||||||
|
def test_repository_fallback_downloads_use_existing_size_and_redirect_checks(self):
|
||||||
|
module = engine()
|
||||||
|
from unittest.mock import patch
|
||||||
|
import io
|
||||||
|
import urllib.request
|
||||||
|
base = 'https://git.shanaty.ru/Emil_Shanaty/Hydra-OS/raw/branch/main/releases/stable/'
|
||||||
|
def oversized(request, timeout):
|
||||||
|
if request.full_url == module.RELEASES:
|
||||||
|
return io.BytesIO(b'[]')
|
||||||
|
return io.BytesIO(b'x' * (module.MANIFEST_LIMIT + 1))
|
||||||
|
with patch('urllib.request.OpenerDirector.open', side_effect=oversized):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'size'):
|
||||||
|
module.discover(self.pub)
|
||||||
|
request = urllib.request.Request(base + 'manifest.json')
|
||||||
|
for destination in ('http://git.shanaty.ru/manifest.json', 'https://other.example/manifest.json'):
|
||||||
|
with self.subTest(destination=destination), self.assertRaises(ValueError):
|
||||||
|
module.SafeRedirect().redirect_request(request, None, 302, '', {}, destination)
|
||||||
|
|||||||
Reference in New Issue
Block a user