Using CAS for content-trust (#3382)

* Using CAS for content-trust

* v2

* Fix linting errors

* Adjust field checked for status in CAS response

* CI workflow needs CAS not VCN now

* Use cwd in test as code won't be in /usr/src

* Pre-cache CAS pub key for supervisor

* Cas doesn't actually need key file executable

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
This commit is contained in:
Pascal Vizeli 2022-02-10 09:21:21 +01:00 committed by GitHub
parent e5d64f6c75
commit 3478005e70
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 100 additions and 67 deletions

View File

@ -33,6 +33,7 @@ on:
- setup.py - setup.py
env: env:
DEFAULT_PYTHON: 3.9
BUILD_NAME: supervisor BUILD_NAME: supervisor
BUILD_TYPE: supervisor BUILD_TYPE: supervisor
WHEELS_TAG: 3.9-alpine3.14 WHEELS_TAG: 3.9-alpine3.14
@ -138,7 +139,7 @@ jobs:
CAS_API_KEY: ${{ secrets.CAS_TOKEN }} CAS_API_KEY: ${{ secrets.CAS_TOKEN }}
codenotary: codenotary:
name: CodeNotary signature name: CAS signature
needs: init needs: init
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -148,6 +149,20 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@v2.3.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true'
id: dirhash
run: |
pip3 install dirhash
dir_hash="$(dirhash "${{ github.workspace }}" -a sha256 --match "*.py")"
echo "::set-output name=dirhash::${dir_hash}"
- name: Set version - name: Set version
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master uses: home-assistant/actions/helpers/version@master
@ -158,10 +173,8 @@ jobs:
if: needs.init.outputs.publish == 'true' if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/codenotary@master uses: home-assistant/actions/helpers/codenotary@master
with: with:
source: dir://${{ github.workspace }} source: hash://${{ steps.dirhash.outputs.dirhash }}
user: ${{ secrets.VCN_USER }} token: ${{ secrets.CAS_TOKEN }}
password: ${{ secrets.VCN_PASSWORD }}
organisation: ${{ secrets.VCN_ORG }}
version: version:
name: Update version name: Update version

View File

@ -10,7 +10,7 @@ on:
env: env:
DEFAULT_PYTHON: 3.9 DEFAULT_PYTHON: 3.9
PRE_COMMIT_HOME: ~/.cache/pre-commit PRE_COMMIT_HOME: ~/.cache/pre-commit
DEFAULT_VCN: v0.9.8 DEFAULT_CAS: v1.0.1
jobs: jobs:
# Separate job to pre-populate the base dependency cache # Separate job to pre-populate the base dependency cache
@ -351,10 +351,10 @@ jobs:
id: python id: python
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install VCN tools - name: Install CAS tools
uses: home-assistant/actions/helpers/vcn@master uses: home-assistant/actions/helpers/cas@master
with: with:
vcn_version: ${{ env.DEFAULT_VCN }} version: ${{ env.DEFAULT_CAS }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.7

View File

@ -5,10 +5,12 @@ ENV \
S6_SERVICES_GRACETIME=10000 \ S6_SERVICES_GRACETIME=10000 \
SUPERVISOR_API=http://localhost SUPERVISOR_API=http://localhost
ARG BUILD_ARCH ARG \
WORKDIR /usr/src BUILD_ARCH \
CAS_VERSION
# Install base # Install base
WORKDIR /usr/src
RUN \ RUN \
set -x \ set -x \
&& apk add --no-cache \ && apk add --no-cache \
@ -18,7 +20,20 @@ RUN \
libffi \ libffi \
libpulse \ libpulse \
musl \ musl \
openssl openssl \
&& apk add --no-cache --virtual .build-dependencies \
build-base \
go \
\
&& git clone -b "v${CAS_VERSION}" --depth 1 \
https://github.com/codenotary/cas \
&& cd cas \
&& make cas \
&& mv cas /usr/bin/cas \
\
&& apk del .build-dependencies \
&& rm -rf /root/go /root/.cache \
&& rm -rf /usr/src/cas
# Install requirements # Install requirements
COPY requirements.txt . COPY requirements.txt .

View File

@ -9,6 +9,8 @@ build_from:
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io
args:
CAS_VERSION: 1.0.1
labels: labels:
io.hass.type: supervisor io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor org.opencontainers.image.title: Home Assistant Supervisor

View File

@ -11,6 +11,7 @@ cpe==1.2.1
cryptography==36.0.1 cryptography==36.0.1
debugpy==1.5.1 debugpy==1.5.1
deepmerge==1.0.1 deepmerge==1.0.1
dirhash==0.2.1
docker==5.0.3 docker==5.0.3
gitpython==3.1.26 gitpython==3.1.26
jinja2==3.0.3 jinja2==3.0.3

View File

@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE03LvYuz79GTJx4uKp3w6NrSe5JZI
iBtgzzYi0YQYtZO/r+xFpgDJEa0gLHkXtl94fpqrFiN89In83lzaszbZtA==
-----END PUBLIC KEY-----

View File

@ -633,7 +633,7 @@ class DockerInterface(CoreSysAttributes):
"""Validate trust of content.""" """Validate trust of content."""
checksum = image_id.partition(":")[2] checksum = image_id.partition(":")[2]
job = asyncio.run_coroutine_threadsafe( job = asyncio.run_coroutine_threadsafe(
self.sys_security.verify_own_content(checksum=checksum), self.sys_loop self.sys_security.verify_own_content(checksum), self.sys_loop
) )
job.result(timeout=20) job.result(timeout=20)

View File

@ -5,6 +5,7 @@ from pathlib import Path
from ...const import CoreState from ...const import CoreState
from ...coresys import CoreSys from ...coresys import CoreSys
from ...exceptions import CodeNotaryError, CodeNotaryUntrusted from ...exceptions import CodeNotaryError, CodeNotaryUntrusted
from ...utils.codenotary import calc_checksum_path_sourcecode
from ..const import UnsupportedReason from ..const import UnsupportedReason
from .base import EvaluateBase from .base import EvaluateBase
@ -41,8 +42,13 @@ class EvaluateSourceMods(EvaluateBase):
_LOGGER.warning("Disabled content-trust, skipping evaluation") _LOGGER.warning("Disabled content-trust, skipping evaluation")
return return
# Calculate sume of the sourcecode
checksum = await self.sys_run_in_executor(
calc_checksum_path_sourcecode, _SUPERVISOR_SOURCE
)
try: try:
await self.sys_security.verify_own_content(path=_SUPERVISOR_SOURCE) await self.sys_security.verify_own_content(checksum)
except CodeNotaryUntrusted: except CodeNotaryUntrusted:
return True return True
except CodeNotaryError: except CodeNotaryError:

View File

@ -1,7 +1,6 @@
"""Fetch last versions from webserver.""" """Fetch last versions from webserver."""
import logging import logging
from pathlib import Path from typing import Awaitable
from typing import Awaitable, Optional
from .const import ( from .const import (
ATTR_CONTENT_TRUST, ATTR_CONTENT_TRUST,
@ -11,7 +10,7 @@ from .const import (
) )
from .coresys import CoreSys, CoreSysAttributes from .coresys import CoreSys, CoreSysAttributes
from .exceptions import CodeNotaryError, CodeNotaryUntrusted, PwnedError from .exceptions import CodeNotaryError, CodeNotaryUntrusted, PwnedError
from .utils.codenotary import vcn_validate from .utils.codenotary import cas_validate
from .utils.common import FileConfiguration from .utils.common import FileConfiguration
from .utils.pwned import check_pwned_password from .utils.pwned import check_pwned_password
from .validate import SCHEMA_SECURITY_CONFIG from .validate import SCHEMA_SECURITY_CONFIG
@ -57,16 +56,14 @@ class Security(FileConfiguration, CoreSysAttributes):
"""Set pwned is enabled/disabled.""" """Set pwned is enabled/disabled."""
self._data[ATTR_PWNED] = value self._data[ATTR_PWNED] = value
async def verify_own_content( async def verify_own_content(self, checksum: str) -> Awaitable[None]:
self, checksum: Optional[str] = None, path: Optional[Path] = None
) -> Awaitable[None]:
"""Verify content from HA org.""" """Verify content from HA org."""
if not self.content_trust: if not self.content_trust:
_LOGGER.warning("Disabled content-trust, skip validation") _LOGGER.warning("Disabled content-trust, skip validation")
return return
try: try:
await vcn_validate(checksum, path, org="home-assistant.io") await cas_validate(checksum=checksum, signer="notary@home-assistant.io")
except CodeNotaryUntrusted: except CodeNotaryUntrusted:
raise raise
except CodeNotaryError: except CodeNotaryError:

View File

@ -127,7 +127,7 @@ class Supervisor(CoreSysAttributes):
# Validate # Validate
try: try:
await self.sys_security.verify_own_content(checksum=calc_checksum(data)) await self.sys_security.verify_own_content(calc_checksum(data))
except CodeNotaryUntrusted as err: except CodeNotaryUntrusted as err:
raise SupervisorAppArmorError( raise SupervisorAppArmorError(
"Content-Trust is broken for the AppArmor profile fetch!", "Content-Trust is broken for the AppArmor profile fetch!",

View File

@ -207,7 +207,7 @@ class Updater(FileConfiguration, CoreSysAttributes):
# Validate # Validate
try: try:
await self.sys_security.verify_own_content(checksum=calc_checksum(data)) await self.sys_security.verify_own_content(calc_checksum(data))
except CodeNotaryUntrusted as err: except CodeNotaryUntrusted as err:
raise UpdaterError( raise UpdaterError(
"Content-Trust is broken for the version file fetch!", _LOGGER.critical "Content-Trust is broken for the version file fetch!", _LOGGER.critical

View File

@ -1,27 +1,28 @@
"""Small wrapper for CodeNotary.""" """Small wrapper for CodeNotary."""
# pylint: disable=unreachable
import asyncio import asyncio
import hashlib import hashlib
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
import shlex import shlex
from typing import Optional, Union from typing import Final, Union
import async_timeout import async_timeout
from dirhash import dirhash
from . import clean_env from . import clean_env
from ..exceptions import CodeNotaryBackendError, CodeNotaryError, CodeNotaryUntrusted from ..exceptions import CodeNotaryBackendError, CodeNotaryError, CodeNotaryUntrusted
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
_VCN_CMD: str = "vcn authenticate --silent --output json" _CAS_CMD: str = (
_CACHE: set[tuple[str, Path, str, str]] = set() "cas authenticate --signerID {signer} --silent --output json --hash {sum}"
)
_CACHE: set[tuple[str, str]] = set()
_ATTR_ERROR = "error" _ATTR_ERROR: Final = "error"
_ATTR_VERIFICATION = "verification" _ATTR_STATUS: Final = "status"
_ATTR_STATUS = "status"
def calc_checksum(data: Union[str, bytes]) -> str: def calc_checksum(data: Union[str, bytes]) -> str:
@ -31,36 +32,24 @@ def calc_checksum(data: Union[str, bytes]) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
async def vcn_validate( def calc_checksum_path_sourcecode(folder: Path) -> str:
checksum: Optional[str] = None, """Calculate checksum for a path source code."""
path: Optional[Path] = None, return dirhash(folder.as_posix(), "sha256", match=["*.py"])
org: Optional[str] = None,
signer: Optional[str] = None,
async def cas_validate(
signer: str,
checksum: str,
) -> None: ) -> None:
"""Validate data against CodeNotary.""" """Validate data against CodeNotary."""
return None if (checksum, signer) in _CACHE:
if (checksum, path, org, signer) in _CACHE:
return return
command = shlex.split(_VCN_CMD)
# Generate command for request # Generate command for request
if org: command = shlex.split(_CAS_CMD.format(signer=signer, sum=checksum))
command.extend(["--org", org])
elif signer:
command.extend(["--signerID", signer])
if checksum:
command.extend(["--hash", checksum])
elif path:
if path.is_dir:
command.append(f"dir://{path.as_posix()}")
else:
command.append(path.as_posix())
else:
RuntimeError("At least path or checksum need to be set!")
# Request notary authorization # Request notary authorization
_LOGGER.debug("Send vcn command: %s", command) _LOGGER.debug("Send cas command: %s", command)
try: try:
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*command, *command,
@ -93,7 +82,7 @@ async def vcn_validate(
if _ATTR_ERROR in data_json: if _ATTR_ERROR in data_json:
raise CodeNotaryBackendError(data_json[_ATTR_ERROR], _LOGGER.warning) raise CodeNotaryBackendError(data_json[_ATTR_ERROR], _LOGGER.warning)
if data_json[_ATTR_VERIFICATION][_ATTR_STATUS] == 0: if data_json[_ATTR_STATUS] == 0:
_CACHE.add((checksum, path, org, signer)) _CACHE.add((checksum, signer))
else: else:
raise CodeNotaryUntrusted() raise CodeNotaryUntrusted()

View File

@ -1,5 +1,7 @@
"""Test evaluation base.""" """Test evaluation base."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error,protected-access
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from supervisor.const import CoreState from supervisor.const import CoreState
@ -10,6 +12,10 @@ from supervisor.resolution.evaluations.source_mods import EvaluateSourceMods
async def test_evaluation(coresys: CoreSys): async def test_evaluation(coresys: CoreSys):
"""Test evaluation.""" """Test evaluation."""
with patch(
"supervisor.resolution.evaluations.source_mods._SUPERVISOR_SOURCE",
Path(os.getcwd()),
):
sourcemods = EvaluateSourceMods(coresys) sourcemods = EvaluateSourceMods(coresys)
coresys.core.state = CoreState.RUNNING coresys.core.state = CoreState.RUNNING