From ef2fb7b8f703c10737a123798348c370a194474c Mon Sep 17 00:00:00 2001 From: Miroslav Shubernetskiy Date: Tue, 12 Mar 2024 21:27:04 -0400 Subject: [PATCH] refactor: attestation uses key providers to generate/retrieve keys instead of directly supporting signing key backup service, attestation now supports fetching keys via generic key providers. Currently 3 providers are implemented: * embed * backup * get (fetches full key via API) In the future we can add more providers such as querying keys from secret manager/etc. In order to achieve that there is top-level attestation object now: ``` attestation { key_provider: "get" # picks which provider to use # configure providers attestation_key_embed {...} attestation_key_backup {...} attestation_key_get {...} } ``` Each provider supports their set of fields such as external providers might require to configure `auth` which will use existing `auth_config`s. To make the code simpler to follow as well as to make UX simpler some changes: * `chalk setup` does not prompt for password anymore. To import existing key password needs to be supplied via `CHALK_PASSWORD` env var. * there are no more `chalk setup load` and `chalk setup gen` subcommands. There is only a single `chalk setup` which either loads key if the provider supports that or generates new key, again if provider supports that. This allowed to remove a lot of complexity in the key loading logic. * all provider logic is in attestation/.nim * to avoid name conflict attestation.nim was renamed to attestation_api.nim which also matches plugin_api.nim * attestation_api.nim now: * handles key retrieval/generation via provider * implements logic to sign/verify signatures (not refactored) --- CHANGELOG.md | 34 + configs/connect.c4m | 23 +- server/Dockerfile | 2 + server/server/api.py | 63 +- src/attestation.nim | 885 ---------------------- src/attestation/backup.nim | 177 +++++ src/attestation/embed.nim | 41 + src/attestation/get.nim | 94 +++ src/attestation/utils.nim | 156 ++++ src/attestation_api.nim | 411 ++++++++++ src/autocomplete/default.bash | 12 +- src/autocomplete/mac.bash | 12 +- src/chalk.nim | 8 +- src/chalk_common.nim | 30 + src/commands/cmd_docker.nim | 4 +- src/commands/cmd_help.nim | 4 +- src/commands/cmd_setup.nim | 34 +- src/configs/attestation.c4m | 18 +- src/configs/base_init.c4m | 9 +- src/configs/chalk.c42spec | 265 +++++-- src/configs/getopts.c4m | 77 +- src/plugins/codecDocker.nim | 2 +- src/plugins/system.nim | 2 +- tests/chalk/runner.py | 4 + tests/conftest.py | 1 - tests/data/configs/attestation/backup.c4m | 12 + tests/data/configs/attestation/embed.c4m | 0 tests/data/configs/attestation/get.c4m | 12 + tests/data/configs/nosigningkeybackup.c4m | 1 - tests/test_command.py | 95 ++- tests/testing.c4m | 2 + 31 files changed, 1355 insertions(+), 1135 deletions(-) delete mode 100644 src/attestation.nim create mode 100644 src/attestation/backup.nim create mode 100644 src/attestation/embed.nim create mode 100644 src/attestation/get.nim create mode 100644 src/attestation/utils.nim create mode 100644 src/attestation_api.nim create mode 100644 tests/data/configs/attestation/backup.c4m create mode 100644 tests/data/configs/attestation/embed.c4m create mode 100644 tests/data/configs/attestation/get.c4m delete mode 100644 tests/data/configs/nosigningkeybackup.c4m diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baf335b..12850582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ ## Main +### Breaking Changes + +- Attestation key generation/retrieval was refactored + to use key providers. As such all previous config + values related to signing backup service have changed. + + Removed attributes: + + - `use_signing_key_backup_service` + - `signing_key_backup_service_url` + - `signing_key_backup_service_auth_config_name` + - `signing_key_backup_service_timeout` + - `signing_key_location` + + Instead now each individual key provider can be separately + configured: + + ``` + attestation { + key_provider: "embed" # or "backup" which enables key backup provider + # as previously configured by + # `use_signing_key_backup_service` + attestation_key_embed { + location: "./chalk." # used to be `signing_key_location` + } + attestation_key_backup { + location: "./chalk." # used to be `signing_key_location` + uri: "https://..." # used to be `signing_key_backup_service_url` + auth: "..." # used to be `signing_key_backup_service_auth_config_name` + timeout: << 1 sec >> # used to be `signing_key_backup_service_timeout` + } + } + ``` + ### Fixes - Fixes a segfault when using secrets backup service diff --git a/configs/connect.c4m b/configs/connect.c4m index b5f55b82..dbd42278 100644 --- a/configs/connect.c4m +++ b/configs/connect.c4m @@ -16,12 +16,32 @@ auth_config crashoverride { ~auth: "jwt" } +func get_crashoverride_reporting_jwt() { + headers := auth_headers("crashoverride") + if contains(headers, "authorization") { + response := url_post("https://chalk.crashoverride.run/v0.1/key-provider/jwt", "", headers) + json := parse_json(response) + token := get(json, "jwt") + return token + } + return "" +} + +auth_config crashoverride_reporting { + ~auth: "jwt" + ~token: memoize("crashoverride_jwt", func get_crashoverride_reporting_jwt() -> string) +} + +attestation { + ~key_provider: "get" +} + sink_config crashoverride { ~enabled: true ~sink: "presign" ~priority: 999999 ~uri: "https://chalk.crashoverride.run/v0.1/report" - ~auth: "crashoverride" + ~auth: "crashoverride_reporting" } custom_report crashoverride { @@ -31,7 +51,6 @@ custom_report crashoverride { ~use_when: ["insert", "build", "exec"] } -use_signing_key_backup_service = true docker.wrap_entrypoint = true run_sbom_tools = true run_sast_tools = true diff --git a/server/Dockerfile b/server/Dockerfile index 85bac667..0af9ae43 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -3,6 +3,8 @@ FROM python:3.11.3-alpine as base ENV VIRTUAL_ENV=/server.env ENV PATH=$VIRTUAL_ENV/bin:$PATH +COPY --from=gcr.io/projectsigstore/cosign:v2.1.1 /ko-app/cosign /usr/local/bin/cosign + # ------------------------------------------------------------------- FROM base as deps diff --git a/server/server/api.py b/server/server/api.py index 3c1d99d0..ee820dd5 100644 --- a/server/server/api.py +++ b/server/server/api.py @@ -2,14 +2,27 @@ # # This file is part of Chalk # (see https://crashoverride.com/docs/chalk) +import asyncio import dataclasses import logging.config -from typing import Any, Optional +import pathlib +import secrets +import shutil +import tempfile +from typing import Annotated, Any, Optional import os import sqlalchemy -from fastapi import Depends, FastAPI, HTTPException, Request, Response, status -from fastapi.responses import RedirectResponse +from fastapi import ( + Body, + Depends, + FastAPI, + HTTPException, + Request, + Response, + status, +) +from fastapi.responses import PlainTextResponse, RedirectResponse from sqlalchemy.orm import Session from .__version__ import __version__ @@ -183,3 +196,47 @@ async def list_reports( async def list_stats(db: Session = Depends(get_db)) -> list[schemas.Stat]: chalk_stats = db.query(models.Stat).all() return [schemas.Stat.model_validate(vars(c)) for c in chalk_stats] + + +cosign = {} + + +@app.get("/cosign") +async def get_cosign(): + global cosign + if not cosign: + with tempfile.TemporaryDirectory() as _tmp: + password = secrets.token_bytes(16).hex() + tmp = pathlib.Path(_tmp) + process = await asyncio.subprocess.create_subprocess_exec( + shutil.which("cosign"), + "generate-key-pair", + "--output-key-prefix", + "chalk", + env={"COSIGN_PASSWORD": password}, + cwd=tmp, + ) + await process.wait() + cosign = { + "privateKey": (tmp / "chalk.key").read_text(), + "publicKey": (tmp / "chalk.pub").read_text(), + "password": password, + } + return cosign + + +backup = {} + + +@app.get("/backup/{key_id}", response_class=PlainTextResponse) +async def get_backup(key_id: str): + try: + return backup[key_id] + except KeyError: + raise HTTPException(status_code=404) + + +@app.put("/backup/{key_id}") +async def put_backup(key_id: str, request: Request): + password = await request.body() + backup[key_id] = password diff --git a/src/attestation.nim b/src/attestation.nim deleted file mode 100644 index ed607ede..00000000 --- a/src/attestation.nim +++ /dev/null @@ -1,885 +0,0 @@ -## -## Copyright (c) 2023-2024, Crash Override, Inc. -## -## This file is part of Chalk -## (see https://crashoverride.com/docs/chalk) -## - -import std/[base64, httpclient, net, os] -import "."/[chalkjson, config, selfextract, sinks] - -const - attestationObfuscator = staticExec( - "dd status=none if=/dev/random bs=1 count=16 | base64").decode() - cosignLoader = "load_attestation_binary(bool) -> string" - #c4mAttest = "push_attestation(string, string, string) -> bool" - -var - cosignTempDir = "" - cosignLoc = "" - cosignPw = "" # Note this is not encrypted in memory. - cosignLoaded = false - signingID = "" - -template withCosignPassword(code: untyped) = - putEnv("COSIGN_PASSWORD", cosignPw) - trace("Adding COSIGN_PASSWORD to env") - - try: - code - finally: - delEnv("COSIGN_PASSWORD") - trace("Removed COSIGN_PASSWORD from env") - -when false: - ## The below code imports keys generated via the OpenSSL PAI. - ## I'd eventually like to not require downloading cosign - ## to get the keys set up. - ## - ## I'm done w/ the OpenSSL part; the rest I'd have to wrap via - ## secretbox. - const - importFlags = ["import-key-pair", "--key", "chalk.pem", - "--output-key-prefix=chalk"] - - {.emit: """ -#include -#include -#include - -char * -BIO_to_string(BIO *bio) { - char *tmp; - char *result; - size_t len; - - len = BIO_get_mem_data(bio, &tmp); - result = (char *)calloc(len + 1, 1); - memcpy(result, tmp, len); - BIO_free(bio); - - return result; -} - -void -generate_keypair(char **s1, char **s2) { - EVP_PKEY *pkey = NULL; - EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL); - BIO *pri = BIO_new(BIO_s_mem()); - BIO *pub = BIO_new(BIO_s_mem()); - - EVP_PKEY_keygen_init(pctx); - EVP_PKEY_keygen(pctx, &pkey); - EVP_PKEY_CTX_free(pctx); - PEM_write_bio_PrivateKey(pri, pkey, NULL, NULL, 0, NULL, NULL); - PEM_write_bio_PUBKEY(pub, pkey); - - char *x = BIO_to_string(pri); - char *y = BIO_to_string(pub); - - *s1 = x; - *s2 = y; -} -""" .} - proc generateKeypair(pri: ptr cstring, pub: ptr cstring) {.importc: - "generate_keypair".} - proc generateKeyMaterial*(cosign: string) = - let - prikey: cstring - pubkey: cstring - - generateKeypair(addr prikey, addr pubkey) - if not tryToWriteFile("chalk.pem", $(prikey)): - raise newException(OSError, "could not write private key to chalk.pem: " & getCurrentExceptionMsg()) - - discard execProcess(cosign, args = importFlags, options={}) - -## End of code that's not compiled in. Again, it does work, it's just -## not finished enough to replace what we already have. - - -proc callTheSigningKeyBackupService(base: string, - prKey: string, - bodytxt: string, - mth: HttpMethod): Option[Response] = - let - # Timeout asssociated with the signing key backup service - timeout: int = cast[int](chalkConfig.getSigningKeyBackupServiceTimeout()) - # Name of the auth config section to load from the config which contains the jwt - auth_config: string = chalkConfig.getSigningKeyBackupServiceAuthConfigName() - - # This is the id that will be used to identify the secret in the API - signingID = sha256Hex(attestationObfuscator & prkey) - - if mth == HttPGet: - trace("Calling Signing Key Backup Service to retrieve key with ID: " & signingID) - else: - trace("Calling Signing Key Backup Service to store key with ID: " & signingID) - - var url: string - if base[^1] == '/': - url = base & signingID - else: - url = base & "/" & signingID - - let authOpt = getAuthConfigByName(auth_config) - if authOpt.isNone(): - error("Could not retrieve Chalk Data API token from configuration profile. Unable to use Signing Key Backup Service.") - return none(Response) - - var - auth = authOpt.get() - headers = newHttpHeaders() - authHeaders = auth.implementation.injectHeaders(auth, headers) - - # Call the API with authz header - rety twice with backoff - try: - let response = safeRequest(url = url, - httpMethod = mth, - headers = authHeaders, - body = bodytxt, - timeout = timeout, - retries = 2, - firstRetryDelayMs = 100) - - trace("Signing Key Backup Service URL: " & url) - trace("Signing Key Backup Service HTTP headers: " & $authHeaders) - trace("Signing Key Backup Service status code: " & response.status) - trace("Signing Key Backup Service response: " & response.body()) - return some(response) - except: - error("Could not call Signing Key Backup Service: " & getCurrentExceptionMsg()) - return none(Response) - -proc backupSigningKeyToService*(content: string, prkey: string): bool = - var - nonce: string - - let - base = chalkConfig.getSigningKeyBackupServiceUrl() - ct = prp(attestationObfuscator, cosignPw, nonce) - - if len(base) == 0: - error("Cannot backup signing key; no Signing Key Backup Service URL configured.") - return false - - let - body = nonce.hex() & ct.hex() - responseOpt = callTheSigningKeyBackupService(base, prkey, body, HttpPut) - - if responseOpt.isNone(): - return false - - let response = responseOpt.get() - trace("Sending encrypted secret: " & body) - if response.code == Http405: - info("This encrypted signing key is already backed up.") - elif not response.code.is2xx(): - error("When attempting to save envcrypted signing key: " & response.status) - trace(response.body()) - return false - else: - info("Successfully stored encrypted signing key.") - warn("Please Note: Encrypted signing keys that have not been READ in the previous 30 days will be deleted!") - return true - -proc restoreSigningKeyFromService*(prkey: string): bool = - - if cosignPw != "": - return true - - let base: string = chalkConfig.getSigningKeyBackupServiceUrl() - - if len(base) == 0 or prkey == "": - return false - - let responseOpt = callTheSigningKeyBackupService(base, prKey, "", HttpGet) - if responseOpt.isNone(): - return false - let response = responseOpt.get() - - if response.code == Http401: - # authentication issue / token expiration - trace("JSON body of response from Signing key Backup Service: " & response.body()) - return false - elif not response.code.is2xx(): - warn("Could not retrieve encrypted signing key: " & response.status & "\n" & "Will not be able to sign / verify.") - return false - - var - body: string - hexBits: string - - try: - hexBits = response.body() - body = parseHexStr($hexBits) - - if len(body) != 40: - error("Encrypted key returned from server is incorrect size. Received" & $len(body) & "bytes, exected 40 bytes.") - return false - - except: - error("When retrieving encrypted key, received an invalid " & - "response from service: " & response.status) - return false - - trace("Successfully retrieved encrypted key from backup service.") - - var - nonce = body[0 ..< 16] - ct = body[16 .. ^1] - - cosignPw = brb(attestationObfuscator, ct, nonce) - - return true - -proc getCosignLocation*(downloadCosign = false): string = - once: - let args = @[pack(downloadCosign)] - cosignLoc = unpack[string](runCallback(cosignLoader, args).get()) - - if cosignLoc == "": - warn("Could not find or install cosign; cannot sign or verify.") - - return cosignLoc - -proc getCosignTempDir(): string = - once: - if cosignTempDir == "": - let - extract = getSelfExtraction().get().extract - priKey = unpack[string](extract["$CHALK_ENCRYPTED_PRIVATE_KEY"]) - pubKey = unpack[string](extract["$CHALK_PUBLIC_KEY"]) - - cosignTempDir = getNewTempDir() - withWorkingDir(cosignTempDir): - if not (tryToWriteFile("chalk.key", priKey) and - tryToWriteFile("chalk.pub", pubKey)): - error("Cannot write to temporary directory; sign and verify " & - "will not work this run.") - cosignTempDir = "" - - return cosignTempDir - -proc getKeyFileLoc*(): string = - let - confLoc = chalkConfig.getSigningKeyLocation() - - if confLoc.endswith(".key") or confLoc.endswith(".pub"): - result = resolvePath(confLoc[0 ..< ^4]) - else: - result = resolvePath(confLoc) - - if dirExists(result): - error("Invalid key file specified; base (without the extension) must " & - "include a file name.") - return "" - - let - (dir, _) = result.splitPath() - - if dirExists(dir): - return - else: - error("Directory '" & dir & "' does not exist.") - return "" - -proc generateKeyMaterial*(cosign: string): bool = - let keyCmd = @["generate-key-pair", "--output-key-prefix", "chalk"] - var results: ExecOutput - - withCosignPassword: - results = runCmdGetEverything(cosign, keyCmd) - - if results.getExit() != 0: - return false - else: - return true - -proc commitPassword(pri: string, gen: bool) = - var - storeIt = chalkConfig.getUseSigningKeyBackupService() - printIt = not storeIt - - if storeIt: - # If the backup service doesn't work, then we need to fall back. - if not cosignPw.backupSigningKeyToService(pri): - error("Could not store password. Either try again later, or " & - "use the below password with the CHALK_PASSWORD environment " & - "variable. We attempt to store as long as use_signing_key_backup_service " & - "is true.\nIf you forget the password, delete chalk.key and " & - "chalk.pub before rerunning.") - - if gen: - printIt = true - - else: - let idString = "The ID of the backed up key is: " & $signingID - info(idString) - - if printIt: - echo "------------------------------------------" - echo "Your password is: ", cosignPw - echo """------------------------------------------ - -Write this down. Even if you embedded it in the Chalk binary, you -will need it to load the key pair into another chalk binary. -""" - - # Right now we are not using the result. -proc acquirePassword(optfile = ""): bool {.discardable.} = - var - prikey = optfile - - # If Env var with signing password is set use that - if existsEnv("CHALK_PASSWORD"): - cosignPw = getEnv("CHALK_PASSWORD") - delEnv("CHALK_PASSWORD") - return true - - if chalkConfig.getUseSigningKeyBackupService() == false: - return false - - if prikey == "": - - let - boxedOpt = selfChalkGetKey("$CHALK_ENCRYPTED_PRIVATE_KEY") - boxed = boxedOpt.getOrElse(pack("")) - - prikey = unpack[string](boxed) - - if prikey == "": - return false - - # Use Chalk Data API key to retrieve previously saved encrypted secret - # from API, then use retrieved private key to decrypt - if restoreSigningKeyFromService(prikey): - return true - else: - error("Could not retrieve encrypted signing key from API") - return false - -proc testSigningSetup(pubKey, priKey: string): bool = - cosignTempDir = getNewTempDir() - - if cosignTempDir == "": - return false - - withWorkingDir(cosignTempDir): - if not (tryToWriteFile("chalk.key", priKey) and - tryToWriteFile("chalk.pub", pubKey)): - error("Cannot write to temporary directory; sign and verify NOT " & - "configured.") - return false - - withCosignPassword: - let - cosign = getCosignLocation() - toSign = "Test string for signing" - signArgs = @["sign-blob", "--tlog-upload=false", "--yes", - "--key=chalk.key", "-"] - - let - signOut = runCmdGetEverything(cosign, signArgs, tosign) - sig = signOut.getStdout() - - if signOut.getExit() != 0 or sig == "": - error("Could not sign; either password is wrong, or key is invalid.") - return false - - info("Test sign successful.") - - let - vfyArgs = @["verify-blob", "--key=chalk.pub", - "--insecure-ignore-tlog=true", - "--insecure-ignore-sct=true", ("--signature=" & sig), "-"] - vfyOut = runCmdGetEverything(cosign, vfyArgs, tosign) - - if vfyOut.getExit() != 0: - error("Could not validate; public key is invalid.") - return false - - info("Test verify successful.") - - return true - -proc writeSelfConfig(selfChalk: ChalkObj): bool {.importc, discardable.} - -proc saveSigningSetup(pubKey, priKey: string, gen: bool): bool = - let selfChalk = getSelfExtraction().get() - - selfChalk.extract["$CHALK_ENCRYPTED_PRIVATE_KEY"] = pack(priKey) - selfChalk.extract["$CHALK_PUBLIC_KEY"] = pack(pubKey) - - commitPassword(priKey, gen) - - when false: - # This is old code, but it might make a comeback at some point, - # so I'm not removing it. - if chalkConfig.getUseInternalPassword(): - let pw = pack(encryptPassword(cosignPw)) - selfChalk.extract["$CHALK_ATTESTATION_TOKEN"] = pw - else: - if "$CHALK_ATTESTATION_TOKEN" in selfChalk.extract: - selfChalk.extract.del("$CHALK_ATTESTATION_TOKEN") - - let savedCommandName = getCommandName() - setCommandName("setup") - result = selfChalk.writeSelfConfig() - setCommandName(savedCommandName) - -proc copyGeneratedKeys(pubKey, priKey, baseLoc: string) = - let - pubLoc = baseLoc & ".pub" - priLoc = baseLoc & ".key" - - if not tryToCopyFile("chalk.pub", pubLoc): - error("Could not copy public key to " & pubLoc & "; printing to stdout") - else: - info("Public key written to: " & pubLoc) - if not tryToCopyFile("chalk.key", priLoc): - error("Could not copy private key to " & priLoc & "; printing to stdout") - else: - info("Public key (encrypted) written to: " & priLoc) - -proc loadSigningSetup(withPrivateKey = false): bool = - let - selfOpt = getSelfExtraction() - - if selfOpt.isNone(): - return false - - let selfChalk = selfOpt.get() - - if selfChalk.extract == nil: - return false - - let extract = selfChalk.extract - - if "$CHALK_PUBLIC_KEY" notin extract: - return false - - withWorkingDir(getCosignTempDir()): - let pubKey = unpack[string](extract["$CHALK_PUBLIC_KEY"]) - if not tryToWriteFile("chalk.pub", pubKey): - return false - - if withPrivateKey: - if "$CHALK_ENCRYPTED_PRIVATE_KEY" notin extract: - return false - - if cosignPw == "": - error("Cannot attest; no password is available for the private key. " & - "Note that the private key *must* be encrypted.") - return false - - let priKey = unpack[string](extract["$CHALK_ENCRYPTED_PRIVATE_KEY"]) - - withWorkingDir(getCosignTempDir()): - if not tryToWriteFile("chalk.key", priKey): - return false - - cosignLoaded = true - return cosignLoaded - -proc attemptToLoadKeys*(withPrivateKey = false, silent=false): bool = - if getCosignLocation() == "": - return false - - let withoutExtension = getKeyFileLoc() - if withoutExtension == "": - return false - - var pubKey = tryToLoadFile(withoutExtension & ".pub") - if pubKey == "": - if not silent: - error("Could not read public key.") - return false - - if not withPrivateKey: - cosignLoaded = true - return true - - var priKey = tryToLoadFile(withoutExtension & ".key") - if priKey == "": - if not silent: - error("Could not read public key.") - return false - - acquirePassword(priKey) - if cosignPw == "": - cosignPw = getPassword("Password: ") - if cosignPw == "": - return false - - if not testSigningSetup(pubKey, priKey): - return false - - cosignLoaded = true - - # Ensure any changed chalk keys are saved to self - result = saveSigningSetup(pubKey, priKey, true) - - return true - -proc attemptToGenKeys*(): bool = - - if getCosignLocation() == "": - return false - - let keyOutLoc = getKeyFileLoc() - - if keyOutLoc == "": - return false - - if cosignTempDir == "": - cosignTempDir = getNewTempDir() - - withWorkingDir(cosignTempDir): - cosignPw = randString(16).encode(safe = true) - - withCosignPassword: - if not generateKeyMaterial(getCosignLocation()): - return false - let - pubKey = tryToLoadFile("chalk.pub") - priKey = tryToLoadFile("chalk.key") - - if pubKey == "" or priKey == "": - return false - - copyGeneratedKeys(pubKey, priKey, keyOutLoc) - cosignLoaded = true - - result = saveSigningSetup(pubKey, priKey, true) - -proc canAttest*(): bool = - if getCosignLocation() == "": - return false - return cosignLoaded - -proc checkSetupStatus*() = - # This should really only be called from chalk.nim. - # Beyond that, call canAttest() - - once: - let - cmd = getBaseCommandName() - # TODO this will require private key for all - # docker commands which is not ideal - # but there is no easy mechanism to determine docker command here - withPrivateKey = cmd in ["insert", "docker"] - if cmd in ["setup", "help", "load", "dump", "version", "env", "exec"]: - return - - if withPrivateKey: - acquirePassword() - - if loadSigningSetup(withPrivateKey = withPrivateKey): - # loadSigningSetup checks for the info we need to sign. If it's true, - # we are good. - return - - let - countOpt = selfChalkGetKey("$CHALK_LOAD_COUNT") - countBox = countOpt.getOrElse(pack(0)) - count = unpack[int](countBox) - - if count == 0: - # Don't auto-load when compiling. - return - - if cosignPw != "": - warn("Found CHALK_PASSWORD; looking for code signing keys.") - if not attemptToLoadKeys(withPrivateKey = withPrivateKey, silent = true): - warn("Could not load code signing keys. Run `chalk setup` to generate") - return - - warn("Code signing not initialized. Run `chalk setup` to fix.") - - - if count == 1: - warn("If you want an easy way to do code signing and want to " & - "get rid of this warning, run:\n" & - " `chalk setup --store-password`.") - warn("The better way is to generate a keypair with `chalk setup` " & - "and store the generated password in a signing key backup service. " & - "See chalk help setup` for more information.") - -proc writeInToto(info: DockerInvocation, - tag: string, - digestStr: string, - mark: string, - cosign: string): bool = - let - randint = secureRand[uint]() - hexval = toHex(randint and 0xffffffffffff'u).toLowerAscii() - path = "chalk-toto-" & hexval & ".json" - tagStr = escapeJson(tag) - hashStr = escapeJson(info.opChalkObj.cachedHash) - toto = """ { - "_type": "https://in-toto.io/Statement/v1", - "subject": [ - { - "name": """ & tagStr & """, - "digest": { "sha256": """ & hashstr & """} - } - ], - "predicateType": - "https://in-toto.io/attestation/scai/attribute-report/v0.2", - "predicate": { - "attributes": [{ - "attribute": "CHALK", - "evidence": """ & mark & """ - }] - } - } -""" - if not tryToWriteFile(path, toto): - raise newException(OSError, "could not write toto to file: " & getCurrentExceptionMsg()) - - #let - # args = @[pack(path), pack(digestStr), pack(cosign)] - # box = runCallback(c4mAttest, args).get() - - #info("c4mpush called with args = " & $(args)) - #result = unpack[bool](box) - - let - log = $(chalkConfig.getUseTransparencyLog()) - args = @["attest", ("--tlog-upload=" & log), "--yes", "--key", - "chalk.key", "--type", "custom", "--predicate", path, - digestStr] - - info("Pushing attestation via: `cosign " & args.join(" ") & "`") - let - allOut = runCmdGetEverything(cosign, args) - code = allout.getExit() - - if code == 0: - return true - else: - return false - -proc callC4mPushAttestation*(info: DockerInvocation, mark: string): bool = - let chalk = info.opChalkObj - - if chalk.repo == "" or chalk.repoHash == "": - trace("Could not find appropriate info needed for attesting") - return false - - trace("Writing chalk mark via in toto attestation for image id " & - chalk.imageId & " with sha256 hash of " & chalk.repoHash) - - withWorkingDir(getCosignTempDir()): - withCosignPassword: - result = info.writeInToto(chalk.repo, - chalk.repo & "@sha256:" & chalk.repoHash, - mark, getCosignLocation()) - if result: - chalk.signed = true - -template pushAttestation*(ctx: DockerInvocation) = - if not canAttest(): - return - - trace("Attempting to write chalk mark to attestation layer") - try: - if not ctx.callC4mPushAttestation(ctx.opChalkObj.getChalkMarkAsStr()): - warn("Attestation failed.") - else: - info("Pushed attestation successfully.") - except: - dumpExOnDebug() - error("Exception occurred during attestation") - delEnv("COSIGN_PASSWORD") - -proc coreVerify(pk: string, chalk: ChalkObj): bool = - ## Used both for validation, and for downloading just the signature - ## after we've signed. - let - noTlog = not chalkConfig.getUseTransparencyLog() - fName = "chalk.pub" - - withWorkingDir(getNewTempDir()): - if not tryToWriteFile(fName, pk): - error(chalk.name & ": Cannot retrieve signature; " & - "Could not write to tmp file") - return true # Don't error that it's invalid. - - let - args = @["verify-attestation", "--key", fName, - "--insecure-ignore-tlog=" & $(noTlog), "--type", "custom", - chalk.repo & "@sha256:" & chalk.repoHash] - cosign = getCosignLocation() - let - allOut = runCmdGetEverything(cosign, args) - res = allout.getStdout() - code = allout.getExit() - - if code != 0: - trace("Verification failed: " & allOut.getStdErr()) - result = false - else: - let - blob = parseJson(res) - sig = blob["signatures"].getElems()[0] - - chalk.collectedData["_SIGNATURE"] = sig.nimJsonToBox() - trace("Signature is: " & $(blob["signatures"].getElems()[0])) - result = true - -proc extractSigAndValidateNonInsert(chalk: ChalkObj) = - if "INJECTOR_PUBLIC_KEY" notin chalk.extract: - warn("Signer did not add their public key to the mark; cannot validate") - chalk.setIfNeeded("_VALIDATED_SIGNATURE", false) - elif chalk.repo == "" or chalk.repoHash == "": - chalk.setIfNeeded("_VALIDATED_SIGNATURE", false) - else: - let - pubKey = unpack[string](chalk.extract["INJECTOR_PUBLIC_KEY"]) - ok = coreVerify(pubKey, chalk) - if ok: - chalk.setIfNeeded("_VALIDATED_SIGNATURE", true) - info(chalk.name & ": Successfully validated signature.") - else: - chalk.setIfNeeded("_INVALID_SIGNATURE", true) - warn(chalk.name & ": Could not extract valid mark from attestation.") - -proc extractSigAndValidateAfterInsert(chalk: ChalkObj) = - let - pubkey = unpack[string](selfChalkGetKey("$CHALK_PUBLIC_KEY").get()) - ok = coreVerify(pubKey, chalk) - - if ok: - info("Confirmed attestation and collected signature.") - else: - warn("Error collecting attestation signature.") - -proc extractAttestationMark*(chalk: ChalkObj): ChalkDict = - result = ChalkDict(nil) - - if not canAttest(): - return - - if chalk.repo == "": - info("Cannot look for attestation mark w/o repo info") - return - - let - refStr = chalk.repo & "@sha256:" & chalk.repoHash - args = @["download", "attestation", refStr] - cosign = getCosignLocation() - - trace("Attempting to download attestation via: cosign " & args.join(" ")) - - let - allout = runCmdGetEverything(cosign, args) - res = allOut.getStdout() - code = allout.getExit() - - if code != 0: - info(chalk.name & ": No attestation found.") - return - - try: - let - json = parseJson(res) - payload = parseJson(json["payload"].getStr().decode()) - data = payload["predicate"]["Data"].getStr().strip() - predicate = parseJson(data)["predicate"] - attrs = predicate["attributes"].getElems()[0] - rawMark = attrs["evidence"] - - chalk.cachedMark = $(rawMark) - - result = extractOneChalkJson(newStringStream(chalk.cachedMark), chalk.name) - info("Successfully extracted chalk mark from attestation.") - except: - info(chalk.name & ": Bad attestation found.") - -proc extractAndValidateSignature*(chalk: ChalkObj) {.exportc,cdecl.} = - if not canAttest(): - return - - if not chalk.signed: - info(chalk.name & ": Not signed.") - - withWorkingDir(getCosignTempDir()): - if getCommandName() in ["build", "push"]: - chalk.extractSigAndValidateAfterInsert() - else: - chalk.extractSigAndValidateNonInsert() - -proc willSignNonContainer*(chalk: ChalkObj): string = - ## sysDict is the chlak dict the metsys plugin is currently - ## operating on. The items in it will get copied into - ## chalk.collectedData after the plugin returns. - - if not canAttest(): - # They've already been warn()'d. - return "" - - # We sign non-container artifacts if either condition is true. - if not (isSubscribedKey("SIGNATURE") or chalkConfig.getAlwaysTryToSign()): - trace("File artifact signing not configured.") - return "" - - # If there's no associated fs ref, it's either a container or - # something we don't have permission to read; either way, it's not - # getting signed in this flow. - if chalk.fsRef == "": - return "" - - let - pubKeyOpt = selfChalkGetKey("$CHALK_PUBLIC_KEY") - - return unpack[string](pubKeyOpt.get()) - -proc signNonContainer*(chalk: ChalkObj, unchalkedMD, metadataMD : string): - string = - let - log = $(chalkConfig.getUseTransparencyLog()) - args = @["sign-blob", ("--tlog-upload=" & log), "--yes", "--key", - "chalk.key", "-"] - blob = unchalkedMD & metadataMD - - trace("signing blob: " & blob ) - withWorkingDir(getCosignTempDir()): - withCosignPassword: - let cosign = getCosignLocation() - let allOutput = runCmdGetEverything(cosign, args, blob & "\n") - - result = allOutput.getStdout().strip() - - if result == "": - error(chalk.name & ": Signing failed. Cosign error: " & - allOutput.getStderr()) - -proc cosignNonContainerVerify*(chalk: ChalkObj, - artHash, mdHash, sig, pk: string): - ValidateResult = - let - log = $(not chalkConfig.getUseTransparencyLog()) - args = @["verify-blob", ("--insecure-ignore-tlog=" & log), - "--key=chalk.pub", ("--signature=" & sig), - "--insecure-ignore-sct=true", "-"] - blob = artHash & mdHash - - trace("blob = >>" & blob & "<<") - withWorkingDir(getNewTempDir()): - if not tryToWriteFile("chalk.pub", pk): - error(chalk.name & ": cannot validate; could not write to tmp file.") - return vNoCosign - - withCosignPassword: - let cosign = getCosignLocation() - let allOutput = runCmdGetEverything(cosign, args, blob & "\n") - - if allOutput.getExit() == 0: - info(chalk.name & ": Signature successfully validated.") - return vSignedOk - else: - info(chalk.name & ": Signature failed. Cosign reported: " & - allOutput.getStderr()) - return vBadSig diff --git a/src/attestation/backup.nim b/src/attestation/backup.nim new file mode 100644 index 00000000..56f69c62 --- /dev/null +++ b/src/attestation/backup.nim @@ -0,0 +1,177 @@ +## +## Copyright (c) 2023-2024, Crash Override, Inc. +## +## This file is part of Chalk +## (see https://crashoverride.com/docs/chalk) +## + +import std/[base64, httpclient, net] +import ".."/[chalk_common, config, sinks, util] +import "."/utils + +const + obfuscatorCmd = "dd status=none if=/dev/random bs=1 count=16 | base64" + attestationObfuscator = staticExec(obfuscatorCmd).decode() + +proc id(self: AttestationKey): string = + return sha256Hex(attestationObfuscator & self.privateKey) + +proc request(self: AttestationKeyProvider, + key: AttestationKey, + body: string, + mth: HttpMethod): Response = + # This is the id that will be used to identify the secret in the API + let signingId = key.id() + + if mth == HttPGet: + trace("Calling Signing Key Backup Service to retrieve key with ID: " & signingID) + else: + trace("Calling Signing Key Backup Service to store key with ID: " & signingID) + + let url = self.backupUrl & "/" & signingId + + var + headers = newHttpHeaders() + authHeaders = self.backupAuth.implementation.injectHeaders(self.backupAuth, headers) + + # Call the API with authz header - rety twice with backoff + try: + let response = safeRequest(url = url, + httpMethod = mth, + headers = authHeaders, + body = body, + timeout = self.backupTimeout, + retries = 2, + firstRetryDelayMs = 100) + + trace("Signing Key Backup Service URL: " & url) + trace("Signing Key Backup Service status code: " & response.status) + trace("Signing Key Backup Service response[:15]: " & response.bodyStream.peekStr(15)) # truncate not to leak secrets + return response + except: + error("Could not call Signing Key Backup Service: " & getCurrentExceptionMsg()) + raise + +proc backup(self: AttestationKeyProvider, + key: AttestationKey) = + var nonce: string + let + ct = prp(attestationObfuscator, key.password, nonce) + body = nonce.hex() & ct.hex() + + trace("Sending encrypted secret: " & body) + let response = self.request(key, body, HttpPut) + if response.code == Http405: + info("This encrypted signing key is already backed up.") + elif not response.code.is2xx(): + trace("Signing key backup service returned: " & response.body()) + raise newException(ValueError, + "When attempting to save encrypted signing key: " & response.status) + else: + info("Successfully stored encrypted signing key.") + warn("Please Note: Encrypted signing keys that have not been READ in the previous 30 days will be deleted!") + +proc restore(self: AttestationKeyProvider, + key: AttestationKey): string = + let response = self.request(key, "", HttpGet) + if not response.code.is2xx(): + raise newException( + ValueError, + "Could not retrieve encrypted signing key: " & + response.status & "\n" & "Will not be able to sign / verify." + ) + + let hexBits = response.body() + var body: string + + try: + body = parseHexStr(hexBits) + if len(body) != 40: + raise newException( + ValueError, + "Encrypted key returned from server is incorrect size. " & + "Received " & $len(body) & "bytes, exected 40 bytes." + ) + + except: + raise newException( + ValueError, + "When retrieving encrypted key, received an invalid " & + "response from service: " & response.status + ) + + trace("Successfully retrieved encrypted key from backup service.") + + let + nonce = body[0 ..< 16] + ct = body[16 .. ^1] + + return brb(attestationObfuscator, ct, nonce) + +proc init(self: AttestationKeyProvider) = + let + backupConfig = chalkConfig.attestationConfig.attestationKeyBackupConfig + authName = backupConfig.getAuth() + location = backupConfig.getLocation() + authOpt = getAuthConfigByName(authName) + url = backupConfig.getUri().removeSuffix("/") + timeout = cast[int](backupConfig.getTimeout()) + + if authOpt.isNone(): + raise newException(ValueError, + "auth_config." & authName & + " is required to use signing key backup service") + + if url == "": + raise newException(ValueError, + "attestation.attestation_key_backup.uri " & + " is required to use signing key backup service") + + self.backupAuth = authOpt.get() + self.backupUrl = url + self.backupTimeout = timeout + self.backupLocation = location + +proc generateKey(self: AttestationKeyProvider): AttestationKey = + result = mintCosignKey(self.backupLocation) + try: + self.backup(key = result) + info("The ID of the backed up key is: " & result.id()) + except: + error(getCurrentExceptionMsg()) + error("Could not backup password. Either try again later, or " & + "use the below password with the CHALK_PASSWORD environment " & + "variable. We attempt to store when attestation.key_provider = 'backup'.\n" & + "If you forget the password, delete chalk.key and " & + "chalk.pub before rerunning.") + echo() + echo("------------------------------------------") + echo("CHALK_PASSWORD=", result.password) + echo("""------------------------------------------) +Write this down. In future chalk commands, you will need +to provide it via CHALK_PASSWORD environment variable. +""") + + +proc retrieveKey(self: AttestationKeyProvider): AttestationKey = + result = getCosignKeyFromDisk(self.backupLocation) + info("Loaded existing attestation keys from: " & self.backupLocation) + try: + self.backup(key = result) + except: + error(getCurrentExceptionMsg()) + error("Could not backup password. You will need to provide " & + "CHALK_PASSWORD environment variable to keep using attestation.") + +proc retrievePassword(self: AttestationKeyProvider, key: AttestationKey): string = + result = self.restore(key) + info("Retrieved attestation key password from Signin Key Backup Service") + +let backupProvider* = AttestationKeyProvider( + name: "backup", + kind: backup, + init: init, + generateKey: generateKey, + retrieveKey: retrieveKey, + retrievePassword: retrievePassword, +) diff --git a/src/attestation/embed.nim b/src/attestation/embed.nim new file mode 100644 index 00000000..a5ae5970 --- /dev/null +++ b/src/attestation/embed.nim @@ -0,0 +1,41 @@ +## +## Copyright (c) 2023-2024, Crash Override, Inc. +## +## This file is part of Chalk +## (see https://crashoverride.com/docs/chalk) +## + +import ".."/[chalk_common, config] +import "."/utils + +proc init(self: AttestationKeyProvider) = + let + embedConfig = chalkConfig.attestationConfig.attestationKeyEmbedConfig + location = embedConfig.getLocation() + self.embedLocation = location + +proc generateKey(self: AttestationKeyProvider): AttestationKey = + result = mintCosignKey(self.embedLocation) + echo() + echo("------------------------------------------") + echo("CHALK_PASSWORD=", result.password) + echo("""------------------------------------------ +Write this down. In future chalk commands, you will need +to provide it via CHALK_PASSWORD environment variable. +""") + +proc retrieveKey(self: AttestationKeyProvider): AttestationKey = + result = getCosignKeyFromDisk(self.embedLocation) + info("Loaded existing attestation keys from: " & self.embedLocation) + +proc retrievePassword(self: AttestationKeyProvider, key: AttestationKey): string = + return getChalkPassword() + +let embedProvider* = AttestationKeyProvider( + name: "embed", + kind: embed, + init: init, + generateKey: generateKey, + retrieveKey: retrieveKey, + retrievePassword: retrievePassword, +) diff --git a/src/attestation/get.nim b/src/attestation/get.nim new file mode 100644 index 00000000..fb957537 --- /dev/null +++ b/src/attestation/get.nim @@ -0,0 +1,94 @@ +## +## Copyright (c) 2024, Crash Override, Inc. +## +## This file is part of Chalk +## (see https://crashoverride.com/docs/chalk) +## + +import std/[httpclient, net] +import ".."/[chalk_common, config, sinks, util] + +proc request(self: AttestationKeyProvider, query = ""): JsonNode = + let + url = self.getUrl & query + var + headers = newHttpHeaders() + authHeaders = self.getAuth.implementation.injectHeaders(self.getAuth, headers) + response: Response + + try: + response = safeRequest(url = url, + httpMethod = HttpGet, + headers = authHeaders, + timeout = self.getTimeout, + retries = 2, + firstRetryDelayMs = 100) + + trace("Signing Key Provider Service URL: " & url) + trace("Signing Key Provider Service status code: " & response.status) + trace("Signing Key Provider Service response[:15]: " & response.bodyStream.peekStr(15)) # truncate not to leak secrets + except: + error("Could not call Signing Key Provider Service: " & getCurrentExceptionMsg()) + raise + + if not response.code.is2xx(): + error("Signing Key Provider Service returned invalid response: " & response.status) + raise newException(ValueError, "API Error") + + try: + return parseJson(response.body()) + except: + error("Signing Key Provider Service returned invalid response: " & getCurrentExceptionMsg()) + raise + +proc init(self: AttestationKeyProvider) = + let + getConfig = chalkConfig.attestationConfig.attestationKeyGetConfig + authName = getConfig.getAuth() + authOpt = getAuthConfigByName(authName) + url = getConfig.getUri().removeSuffix("/") + timeout = cast[int](getConfig.getTimeout()) + + if authOpt.isNone(): + raise newException(ValueError, + "auth_config." & authName & + " is required to use signing key retrieval service") + + if url == "": + raise newException(ValueError, + "attestation.attestation_key_get.uri " & + " is required to use signing key retrieval service") + + self.getAuth = authOpt.get() + self.getUrl = url + self.getTimeout = timeout + +proc retrieveKey(self: AttestationKeyProvider): AttestationKey = + let + data = self.request() + key = AttestationKey( + privateKey: data{"privateKey"}.getStr(""), + publicKey: data{"publicKey"}.getStr(""), + password: data{"password"}.getStr(""), + ) + if key.privateKey == "" or key.publicKey == "" or key.password == "": + raise newException(ValueError, "Signing Key Provider Service did not return valid attestation key") + info("Retrieved attestion key from Signing Key Provider Service") + return key + +proc retrievePassword(self: AttestationKeyProvider, key: AttestationKey): string = + let + data = self.request(query = "?only=password") + password = data{"password"}.getStr("") + if password == "": + raise newException(ValueError, "Signing Key Provider Service did not return key password") + info("Retrieved attestation key password from Signin Key Provider Service") + return password + +let getProvider* = AttestationKeyProvider( + name: "get", + kind: get, + init: init, + retrieveKey: retrieveKey, + retrievePassword: retrievePassword, +) diff --git a/src/attestation/utils.nim b/src/attestation/utils.nim new file mode 100644 index 00000000..ba640d83 --- /dev/null +++ b/src/attestation/utils.nim @@ -0,0 +1,156 @@ +## +## Copyright (c) 2023-2024, Crash Override, Inc. +## +## This file is part of Chalk +## (see https://crashoverride.com/docs/chalk) +## + +import std/[base64, os] +import ".."/[chalk_common, config, util] + +var cosignLoc: string +proc getCosignLocation*(downloadCosign = false): string = + once: + const cosignLoader = "load_attestation_binary(bool) -> string" + let args = @[pack(downloadCosign)] + cosignLoc = unpack[string](runCallback(cosignLoader, args).get()) + if cosignLoc == "": + warn("Could not find or install cosign; cannot sign or verify.") + return cosignLoc + +proc canAttest*(key: AttestationKey): bool = + if key == nil: + return false + return ( + getCosignLocation() != "" and + key.privateKey != "" and + key.publicKey != "" and + key.password != "" + ) + +proc canAttestVerify*(key: AttestationKey): bool = + if key == nil: + return false + return ( + getCosignLocation() != "" and + key.publicKey != "" + ) + +template withCosignPassword(password: string, code: untyped) = + putEnv("COSIGN_PASSWORD", password) + trace("Adding COSIGN_PASSWORD to env") + try: + code + finally: + delEnv("COSIGN_PASSWORD") + trace("Removed COSIGN_PASSWORD from env") + +template withCosignKey*(key: AttestationKey, code: untyped) = + if key.tmpPath == "": + key.tmpPath = getNewTempDir() + var wrotePrivateKey, wrotePublicKey = true + if key.privateKey != "": + wrotePrivateKey = tryToWriteFile(key.tmpPath / "chalk.key", key.privateKey) + if key.publicKey != "": + wrotePublicKey = tryToWriteFile(key.tmpPath / "chalk.pub", key.publicKey) + if not (wrotePrivateKey and wrotePublicKey): + error("Cannot write to temporary directory; sign and verify " & + "will not work this run.") + key.tmpPath = "" + + withWorkingDir(key.tmpPath): + if key.password != "": + withCosignPassword(key.password): + code + else: + code + +proc isValid*(self: AttestationKey): bool = + self.withCosignKey: + let + cosign = getCosignLocation() + toSign = "Test string for signing" + signArgs = @["sign-blob", + "--tlog-upload=false", + "--yes", + "--key=chalk.key", + "-"] + + let + cmd = runCmdGetEverything(cosign, signArgs, tosign) + err = cmd.getStdErr() + sig = cmd.getStdOut() + + if cmd.getExit() != 0 or sig == "": + error("Could not sign; either password is wrong, or key is invalid: " & sig & " " & err) + return false + + info("Test sign successful.") + + let + vfyArgs = @["verify-blob", "--key=chalk.pub", + "--insecure-ignore-tlog=true", + "--insecure-ignore-sct=true", ("--signature=" & sig), "-"] + vfyOut = runCmdGetEverything(cosign, vfyArgs, tosign) + + if vfyOut.getExit() != 0: + error("Could not validate; public key is invalid.") + return false + + info("Test verify successful.") + + return true + +proc getChalkPassword*(): string = + if not existsEnv("CHALK_PASSWORD"): + raise newException(ValueError, "CHALK_PASSWORD env var is missing") + result = getEnv("CHALK_PASSWORD") + delEnv("CHALK_PASSWORD") + +proc normalizeKeyPath(path: string): tuple[publicKey: string, privateKey: string] = + let + resolved = path.resolvePath() + (dir, name, _) = resolved.splitFile() + publicKey = dir / name & ".pub" + privateKey = dir / name & ".key" + return (publicKey, privateKey) + +proc getCosignKeyFromDisk*(path: string, password = ""): AttestationKey = + let + pass = if password != "": password else: getChalkPassword() + paths = normalizeKeyPath(path) + publicKey = tryToLoadFile(paths.publicKey) + privateKey = tryToLoadFile(paths.privateKey) + + if publicKey == "": + raise newException(ValueError, "Cosign generated invalid public key") + if privateKey == "": + raise newException(ValueError, "Cosign generated invalid private key") + + return AttestationKey( + password: pass, + publicKey: publicKey, + privateKey: privateKey, + ) + +proc mintCosignKey*(path: string): AttestationKey = + let + password = randString(16).encode(safe = true) + (dir, name, _) = path.splitFile() + paths = normalizeKeyPath(path) + keyCmd = @["generate-key-pair", "--output-key-prefix", dir / name] + + if not dir.dirExists(): + dir.createDir() + + if paths.publicKey.fileExists(): + raise newException(ValueError, paths.publicKey & ": already exists. Remove to generate new key.") + if paths.privateKey.fileExists(): + raise newException(ValueError, paths.privateKey & ": already exists. Remove to generate new key.") + + withCosignPassword(password): + let results = runCmdGetEverything(getCosignLocation(), keyCmd) + if results.getExit() != 0: + raise newException(ValueError, "Could not mint cosign key: " & getCurrentExceptionMsg()) + + return getCosignKeyFromDisk(path, password = password) diff --git a/src/attestation_api.nim b/src/attestation_api.nim new file mode 100644 index 00000000..6f6e7509 --- /dev/null +++ b/src/attestation_api.nim @@ -0,0 +1,411 @@ +## +## Copyright (c) 2023-2024, Crash Override, Inc. +## +## This file is part of Chalk +## (see https://crashoverride.com/docs/chalk) +## + +import std/[base64, net, os] +import "."/[chalk_common, chalkjson, config, selfextract] +import ./attestation/[embed, backup, get, utils] +export getCosignLocation # from utils + +proc writeSelfConfig(selfChalk: ChalkObj): bool {.importc, discardable.} + +let keyProviders = { + "embed": embedProvider, + "backup": backupProvider, + "get": getProvider, +}.toTable() + +var cosignKey = AttestationKey(nil) + +proc canAttest*(): bool = + return cosignKey.canAttest() + +proc getProvider(): AttestationKeyProvider = + let name = chalk_common.chalkConfig.attestationConfig.getKeyProvider() + if name notin keyProviders: + raise newException(KeyError, "Unsupported attestation key provider: " & name) + return keyProviders[name] + +proc loadCosignKeyFromSelf(): AttestationKey = + result = AttestationKey() + + let selfOpt = getSelfExtraction() + if selfOpt.isNone(): + return result + + let selfChalk = selfOpt.get() + if selfChalk.extract == nil: + return result + + let extract = selfChalk.extract + if "$CHALK_PUBLIC_KEY" in extract: + result.publicKey = unpack[string](extract["$CHALK_PUBLIC_KEY"]) + if "$CHALK_ENCRYPTED_PRIVATE_KEY" in extract: + result.privateKey = unpack[string](extract["$CHALK_ENCRYPTED_PRIVATE_KEY"]) + +proc loadAttestation*() = + # This should really only be called from chalk.nim. + # Beyond that, call canAttest() + + once: + let + countOpt = selfChalkGetKey("$CHALK_LOAD_COUNT") + countBox = countOpt.getOrElse(pack(0)) + count = unpack[int](countBox) + + if count == 0: + # Don't auto-load when compiling. + return + + let + cmd = getBaseCommandName() + # TODO this will require private key for all + # docker commands which is not ideal + # but there is no easy mechanism to determine docker command here + withPrivateKey = cmd in ["insert", "docker"] + if cmd in ["setup", "help", "load", "dump", "version", "env", "exec"]: + return + + cosignKey = loadCosignKeyFromSelf() + if cosignKey == nil or not cosignKey.canAttestVerify(): + warn("Code signing not initialized. Run `chalk setup` to fix.") + # there is no key in self chalkmark + return + + let provider = getProvider() + try: + provider.init(provider) + except: + error("Attestation key provider is misconfigured: " & getCurrentExceptionMsg()) + return + + if withPrivateKey: + try: + cosignKey.password = provider.retrievePassword(provider, cosignKey) + except: + error("Could not retrieve cosign private key password: " & getCurrentExceptionMsg()) + return + + try: + if not cosignKey.isValid(): + cosignKey = AttestationKey(nil) + return + except: + cosignKey = AttestationKey(nil) + return + +proc saveKeyToSelf(key: AttestationKey): bool = + let selfChalk = getSelfExtraction().get() + selfChalk.extract["$CHALK_ENCRYPTED_PRIVATE_KEY"] = pack(key.privateKey) + selfChalk.extract["$CHALK_PUBLIC_KEY"] = pack(key.publicKey) + + let savedCommandName = getCommandName() + setCommandName("setup") + try: + result = selfChalk.writeSelfConfig() + finally: + setCommandName(savedCommandName) + +proc setupAttestation*() = + info("Ensuring cosign is present to setup attestation.") + try: + if getCosignLocation(downloadCosign = true) == "": + raise newException(ValueError, "Failed to get cosign binary") + except: + raise newException(ValueError, "Failed to get cosign binary: " & getCurrentExceptionMsg()) + + let provider = getProvider() + try: + provider.init(provider) + except: + raise newException(ValueError, "Attestation key provider is misconfigured: " & getCurrentExceptionMsg()) + + # a bit of nesting of exceptions to propagate appropriate error + # to the user as key providers can optionally implmenet + # retrieval/generation + try: + if provider.retrieveKey != nil: + cosignKey = provider.retrieveKey(provider) + if cosignKey == nil: + raise newException(ValueError, "Retrieved invalid key") + else: + raise newException(ValueError, "Existing key retrieval not supported") + except: + if provider.generateKey == nil: + raise newException(ValueError, "Provider '" & provider.name & "': " & getCurrentExceptionMsg()) + try: + cosignKey = provider.generateKey(provider) + if cosignKey == nil: + raise newException(ValueError, "Generated invalid key") + except: + raise newException(ValueError, "Provider '" & provider.name & "': " & getCurrentExceptionMsg()) + + try: + if not cosignKey.saveKeyToSelf(): + raise newException(ValueError, "Failed to store generated attestation key to chalk") + except: + raise newException(ValueError, "Failed to store generated attestation key to chalk: " & getCurrentExceptionMsg()) + +# ---------------------------------------------------------------------------- +# Everything below is related to actually signing/validating an artifact + +proc writeInToto(info: DockerInvocation, + tag: string, + digestStr: string, + mark: string, + cosign: string): bool = + let + randint = secureRand[uint]() + hexval = toHex(randint and 0xffffffffffff'u).toLowerAscii() + path = "chalk-toto-" & hexval & ".json" + tagStr = escapeJson(tag) + hashStr = escapeJson(info.opChalkObj.cachedHash) + toto = """ { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": """ & tagStr & """, + "digest": { "sha256": """ & hashstr & """} + } + ], + "predicateType": + "https://in-toto.io/attestation/scai/attribute-report/v0.2", + "predicate": { + "attributes": [{ + "attribute": "CHALK", + "evidence": """ & mark & """ + }] + } + } +""" + if not tryToWriteFile(path, toto): + raise newException(OSError, "could not write toto to file: " & getCurrentExceptionMsg()) + + let + log = $(chalkConfig.getUseTransparencyLog()) + args = @["attest", ("--tlog-upload=" & log), "--yes", "--key", + "chalk.key", "--type", "custom", "--predicate", path, + digestStr] + + info("Pushing attestation via: `cosign " & args.join(" ") & "`") + let + allOut = runCmdGetEverything(cosign, args) + code = allout.getExit() + + if code == 0: + return true + else: + return false + +proc callC4mPushAttestation*(info: DockerInvocation, mark: string): bool = + let chalk = info.opChalkObj + + if chalk.repo == "" or chalk.repoHash == "": + trace("Could not find appropriate info needed for attesting") + return false + + trace("Writing chalk mark via in toto attestation for image id " & + chalk.imageId & " with sha256 hash of " & chalk.repoHash) + + cosignKey.withCosignKey: + result = info.writeInToto(chalk.repo, + chalk.repo & "@sha256:" & chalk.repoHash, + mark, getCosignLocation()) + if result: + chalk.signed = true + +template pushAttestation*(ctx: DockerInvocation) = + if not canAttest(): + return + + trace("Attempting to write chalk mark to attestation layer") + try: + if not ctx.callC4mPushAttestation(ctx.opChalkObj.getChalkMarkAsStr()): + warn("Attestation failed.") + else: + info("Pushed attestation successfully.") + except: + dumpExOnDebug() + error("Exception occurred during attestation") + +proc coreVerify(key: AttestationKey, chalk: ChalkObj): bool = + ## Used both for validation, and for downloading just the signature + ## after we've signed. + const fName = "chalk.pub" + let noTlog = not chalkConfig.getUseTransparencyLog() + + key.withCosignKey: + let + args = @["verify-attestation", + "--key", fName, + "--insecure-ignore-tlog=" & $(noTlog), + "--type", "custom", + chalk.repo & "@sha256:" & chalk.repoHash] + cosign = getCosignLocation() + let + allOut = runCmdGetEverything(cosign, args) + res = allout.getStdout() + code = allout.getExit() + + if code != 0: + trace("Verification failed: " & allOut.getStdErr()) + result = false + else: + let + blob = parseJson(res) + sig = blob["signatures"].getElems()[0] + + chalk.collectedData["_SIGNATURE"] = sig.nimJsonToBox() + trace("Signature is: " & $(blob["signatures"].getElems()[0])) + result = true + +proc extractSigAndValidateNonInsert(chalk: ChalkObj) = + if "INJECTOR_PUBLIC_KEY" notin chalk.extract: + warn("Signer did not add their public key to the mark; cannot validate") + chalk.setIfNeeded("_VALIDATED_SIGNATURE", false) + elif chalk.repo == "" or chalk.repoHash == "": + chalk.setIfNeeded("_VALIDATED_SIGNATURE", false) + else: + let + pubKey = unpack[string](chalk.extract["INJECTOR_PUBLIC_KEY"]) + key = AttestationKey(publicKey: pubKey) + ok = coreVerify(key, chalk) + if ok: + chalk.setIfNeeded("_VALIDATED_SIGNATURE", true) + info(chalk.name & ": Successfully validated signature.") + else: + chalk.setIfNeeded("_INVALID_SIGNATURE", true) + warn(chalk.name & ": Could not extract valid mark from attestation.") + +proc extractSigAndValidateAfterInsert(chalk: ChalkObj) = + let ok = coreVerify(cosignKey, chalk) + if ok: + info("Confirmed attestation and collected signature.") + else: + warn("Error collecting attestation signature.") + +proc extractAndValidateSignature*(chalk: ChalkObj) {.exportc,cdecl.} = + if not cosignKey.canAttestVerify(): + return + if not chalk.signed: + info(chalk.name & ": Not signed.") + if getCommandName() in ["build", "push"]: + chalk.extractSigAndValidateAfterInsert() + else: + chalk.extractSigAndValidateNonInsert() + +proc extractAttestationMark*(chalk: ChalkObj): ChalkDict = + result = ChalkDict(nil) + + if not cosignKey.canAttestVerify(): + return + + if chalk.repo == "": + info("Cannot look for attestation mark w/o repo info") + return + + let + refStr = chalk.repo & "@sha256:" & chalk.repoHash + args = @["download", "attestation", refStr] + cosign = getCosignLocation() + + trace("Attempting to download attestation via: cosign " & args.join(" ")) + + let + allout = runCmdGetEverything(cosign, args) + res = allOut.getStdout() + code = allout.getExit() + + if code != 0: + info(chalk.name & ": No attestation found.") + return + + try: + let + json = parseJson(res) + payload = parseJson(json["payload"].getStr().decode()) + data = payload["predicate"]["Data"].getStr().strip() + predicate = parseJson(data)["predicate"] + attrs = predicate["attributes"].getElems()[0] + rawMark = attrs["evidence"] + + chalk.cachedMark = $(rawMark) + + result = extractOneChalkJson(newStringStream(chalk.cachedMark), chalk.name) + info("Successfully extracted chalk mark from attestation.") + except: + info(chalk.name & ": Bad attestation found.") + +proc willSignNonContainer*(chalk: ChalkObj): string = + ## sysDict is the chlak dict the metsys plugin is currently + ## operating on. The items in it will get copied into + ## chalk.collectedData after the plugin returns. + + if not canAttest(): + # They've already been warn()'d. + return "" + + # We sign non-container artifacts if either condition is true. + if not (isSubscribedKey("SIGNATURE") or chalkConfig.getAlwaysTryToSign()): + trace("File artifact signing not configured.") + return "" + + # If there's no associated fs ref, it's either a container or + # something we don't have permission to read; either way, it's not + # getting signed in this flow. + if chalk.fsRef == "": + return "" + + let + pubKeyOpt = selfChalkGetKey("$CHALK_PUBLIC_KEY") + + return unpack[string](pubKeyOpt.get()) + +proc signNonContainer*(chalk: ChalkObj, unchalkedMD, metadataMD : string): + string = + let + log = $(chalkConfig.getUseTransparencyLog()) + args = @["sign-blob", ("--tlog-upload=" & log), "--yes", "--key", + "chalk.key", "-"] + blob = unchalkedMD & metadataMD + + trace("signing blob: " & blob ) + cosignKey.withCosignKey: + let cosign = getCosignLocation() + let allOutput = runCmdGetEverything(cosign, args, blob & "\n") + + result = allOutput.getStdout().strip() + + if result == "": + error(chalk.name & ": Signing failed. Cosign error: " & + allOutput.getStderr()) + +proc cosignNonContainerVerify*(chalk: ChalkObj, + artHash, mdHash, sig, pk: string): + ValidateResult = + let + log = $(not chalkConfig.getUseTransparencyLog()) + args = @["verify-blob", + "--insecure-ignore-tlog=" & log, + "--key=chalk.pub", + "--signature=" & sig, + "--insecure-ignore-sct=true", + "-"] + blob = artHash & mdHash + key = AttestationKey(publicKey: pk) + + trace("blob = >>" & blob & "<<") + key.withCosignKey: + let cosign = getCosignLocation() + let allOutput = runCmdGetEverything(cosign, args, blob & "\n") + + if allOutput.getExit() == 0: + info(chalk.name & ": Signature successfully validated.") + return vSignedOk + else: + info(chalk.name & ": Signature failed. Cosign reported: " & + allOutput.getStderr()) + return vBadSig diff --git a/src/autocomplete/default.bash b/src/autocomplete/default.bash index a44ebb6a..5ea174f1 100644 --- a/src/autocomplete/default.bash +++ b/src/autocomplete/default.bash @@ -11,20 +11,10 @@ # Until then, maintain it manually. -function _chalk_setup_either { - COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior --skip-summary-report --no-skip-summary-report --store-password --no-store-password --key-file" -- ${_CHALK_CUR_WORD})) -} - function _chalk_setup_completions { case ${COMP_WORDS[${_CHALK_CUR_IX}]} in - gen) - _chalk_setup_either - ;; - load) - _chalk_setup_either - ;; *) - COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior --skip-summary-report --no-skip-summary-report --store-password --key-file gen load" -- ${_CHALK_CUR_WORD})) + COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior --skip-summary-report --no-skip-summary-report" -- ${_CHALK_CUR_WORD})) ;; esac } diff --git a/src/autocomplete/mac.bash b/src/autocomplete/mac.bash index 758bc599..e086b8de 100644 --- a/src/autocomplete/mac.bash +++ b/src/autocomplete/mac.bash @@ -6,20 +6,10 @@ ## (see https://crashoverride.com/docs/chalk) ## -function _chalk_setup_either { - COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior --store-password --no-store-password --key-file" -- ${_CHALK_CUR_WORD})) -} - function _chalk_setup_completions { case ${COMP_WORDS[${_CHALK_CUR_IX}]} in - gen) - _chalk_setup_either - ;; - load) - _chalk_setup_either - ;; *) - COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior --store-password --key-file gen load" -- ${_CHALK_CUR_WORD})) + COMPREPLY=($(compgen -W "--color --no-color --help --log-level --config-file --enable-report --disable-report --report-cache-file --time --no-time --use-embedded-config --use-external-config --no-use-external-config --show-config --no-show-config --use-report-cache --no-use-report-cache --debug --no-debug --skip-command-report --no-skip-command-report --symlink-behavior" -- ${_CHALK_CUR_WORD})) ;; esac } diff --git a/src/chalk.nim b/src/chalk.nim index f1f815a6..8d387832 100644 --- a/src/chalk.nim +++ b/src/chalk.nim @@ -8,7 +8,7 @@ # Note that imports cause topics and plugins to register. {.warning[UnusedImport]: off.} import "."/[config, confload, commands, norecurse, sinks, docker_base, - attestation, util] + attestation_api, util] when isMainModule: setupSignalHandlers() # util.nim @@ -25,7 +25,7 @@ when isMainModule: runChalkHelp(getCommandName()) # no return; in cmd_help.nim setupDefaultLogConfigs() # src/sinks.nim - checkSetupStatus() # attestation.nim + loadAttestation() # attestation.nim case getCommandName() # config.nim of "extract": runCmdExtract(chalkConfig.getArtifactSearchPath()) of "extract.containers": runCmdExtractContainers() @@ -42,9 +42,7 @@ when isMainModule: of "version": runCmdVersion() of "docker": runCmdDocker(getArgs()) of "exec": runCmdExec(getArgs()) - of "setup": runCmdSetup(gen=true, load=true) - of "setup.gen": runCmdSetup(gen=true, load=false) - of "setup.load": runCmdSetup(gen=false, load=true) + of "setup": runCmdSetup() of "docgen": runChalkDocGen() # in cmd_help else: runChalkHelp(getCommandName()) # noreturn, will not show config. diff --git a/src/chalk_common.nim b/src/chalk_common.nim index 40ed4905..67086162 100644 --- a/src/chalk_common.nim +++ b/src/chalk_common.nim @@ -102,6 +102,36 @@ type # This is only used when using the default script chalking. commentStart*: string + AttestationKey* = ref object + password*: string + publicKey*: string + privateKey*: string + tmpPath*: string + + AttestationKeyProviderType* = enum + embed + backup + get + + AttestationKeyProvider* = ref object + init*: proc (self: AttestationKeyProvider) + generateKey*: proc (self: AttestationKeyProvider): AttestationKey + retrieveKey*: proc (self: AttestationKeyProvider): AttestationKey + retrievePassword*: proc (self: AttestationKeyProvider, key: AttestationKey): string + name*: string + case kind*: AttestationKeyProviderType + of embed: + embedlocation*: string + of backup: + backupLocation*: string + backupUrl*: string + backupTimeout*: int + backupAuth*: AuthConfig + of get: + getUrl*: string + getTimeout*: int + getAuth*: AuthConfig + KeyType* = enum KtChalkableHost, KtChalk, KtNonChalk, KtHostOnly CollectionCtx* = ref object diff --git a/src/commands/cmd_docker.nim b/src/commands/cmd_docker.nim index a7a148a4..6f7bffcd 100644 --- a/src/commands/cmd_docker.nim +++ b/src/commands/cmd_docker.nim @@ -24,7 +24,7 @@ import std/[posix, unicode] import ".."/[config, collect, reporting, chalkjson, docker_cmdline, docker_base, - subscan, dockerfile, util, attestation, commands/cmd_help, + subscan, dockerfile, util, attestation_api, commands/cmd_help, plugin_api] {.warning[CStringConv]: off.} @@ -772,7 +772,7 @@ template postDockerActivity() = info("Collecting post-push runtime data") ctx.opChalkObj.collectRunTimeArtifactInfo() trace("About to call into validate.") - attestation.extractAndValidateSignature(ctx.opChalkObj) + attestation_api.extractAndValidateSignature(ctx.opChalkObj) except: dumpExOnDebug() error("Docker attestation failed.") diff --git a/src/commands/cmd_help.nim b/src/commands/cmd_help.nim index d18c69ce..c4df2f0d 100644 --- a/src/commands/cmd_help.nim +++ b/src/commands/cmd_help.nim @@ -18,7 +18,7 @@ const allConfigVarSections = ["", "docker", "exec", "extract", "env_config", # Same here, should generate via API. const allCommandSections = ["", "insert", "docker", "extract", "extract.images", "extract.containers", "extract.all", "exec", - "setup", "setup.gen", "setup.load", "env", "docgen", + "setup", "env", "docgen", "config", "dump", "load", "delete", "version"] proc kindEnumToString(s, v: string): string = @@ -515,8 +515,6 @@ proc runChalkHelp*(cmdName = "help") {.noreturn.} = toOut += con4mRuntime.getCommandDocs("extract.all") of "setup": toOut += con4mRuntime.getCommandDocs("setup") - toOut += con4mRuntime.getCommandDocs("setup.gen") - toOut += con4mRuntime.getCommandDocs("setup.load") of "commands": toOut += con4mRuntime.getCommandDocs("") of "configuration", "configurations", "conffile", "configs", "conf": diff --git a/src/commands/cmd_setup.nim b/src/commands/cmd_setup.nim index e9d3571e..e7a20c0c 100644 --- a/src/commands/cmd_setup.nim +++ b/src/commands/cmd_setup.nim @@ -7,43 +7,23 @@ ## The `chalk setup` command. -import ".."/[config, attestation, reporting, selfextract, util, collect] +import ".."/[config, attestation_api, reporting, selfextract, util, collect] -proc runCmdSetup*(gen, load: bool) = +proc runCmdSetup*() = setCommandName("setup") initCollection() let selfChalk = getSelfExtraction().getOrElse(nil) - if selfChalk == nil: error("Platform does not support self-chalking.") return selfChalk.addToAllChalks() - info("Ensuring cosign is present to setup attestation.") - - if getCosignLocation(downloadCosign = true) == "": - quitChalk(1) - if load: - # If we fall back to 'gen' we don't want attemptToLoadKeys - # to give an error when we don't find keys. - if attemptToLoadKeys(withPrivateKey=true, silent=gen): - doReporting() - return - let - base = getKeyFileLoc() - if not gen: - error("Failed to load signing keys. Aborting.") - quitChalk(1) - elif fileExists(base & ".pub") or fileExists(base & ".key"): - error("Keypair failed to load, but key file(s) are present. Move or " & - "remove in order to regenerate.") - quitChalk(1) - - if attemptToGenKeys(): + try: + setupAttestation() doReporting() - return - else: - error("Failed to generate signing keys. Aborting.") + quitChalk(0) + except: + error(getCurrentExceptionMsg()) quitChalk(1) diff --git a/src/configs/attestation.c4m b/src/configs/attestation.c4m index 040e9d9e..7516bd13 100644 --- a/src/configs/attestation.c4m +++ b/src/configs/attestation.c4m @@ -11,25 +11,9 @@ # installs attestation and returns true if it succeeded. func install_cosign() { - # Follow https://docs.sigstore.dev/attestation/installation/ + # Follow https://docs.sigstore.dev/system_config/installation/ # to install attestation - # - # First attempt to install the go version - go_path := find_exe("go", []) - - if go_path != "" { - info("Go-install'ing cosign.....") - install_out, ok := system("go install github.com/sigstore/cosign/v2/cmd/cosign@latest") - if ok != 0 { - warn("unable to install attestation into $GOPATH/bin/cosign; " + - "attempting curl install") - } else { - return true - } - } - - # go was not present (or errored) - attempt to install via curl in /tmp host_arch := arch() host_os := osname() if host_os == "macosx" { diff --git a/src/configs/base_init.c4m b/src/configs/base_init.c4m index 2378b732..a3181f82 100644 --- a/src/configs/base_init.c4m +++ b/src/configs/base_init.c4m @@ -26,6 +26,11 @@ docker { } load { } cloud_provider { - cloud_instance_hw_identifiers { - } + cloud_instance_hw_identifiers { } +} + +attestation { + attestation_key_embed { } + attestation_key_backup { } + attestation_key_get { } } diff --git a/src/configs/chalk.c42spec b/src/configs/chalk.c42spec index 2b1a5d37..4a4dd892 100644 --- a/src/configs/chalk.c42spec +++ b/src/configs/chalk.c42spec @@ -1177,6 +1177,178 @@ auth_config my_auth_config { """ } +singleton attestation_key_embed { + gen_fieldname: "attestationKeyEmbedConfig" + gen_typename: "AttestationKeyEmbedConfig" + gen_setters: false + user_def_ok: true + doc: """ +Attestation key provider which embeds the signing keys into chalk binary. + +When generating the key, the password will be written to stdout a single time. +Store that value appropriately as it will need to be provided back to +chalk as `CHALK_PASSWORD` environment variable in subsequent operations. +""" + + field location { + type: string + default: "./chalk.key" + validator: func validate_key_path + shortdoc: "Signing key location" + doc: """ +This is only used for the `chalk setup` command; it dictates where to +either find a key pair to load, or where to write a keypair being +generated. If loading existing key `CHALK_PASSWORD` environment variable +is required. + +Chalk will also embed the key-pairs internally, for future operations. +""" + } +} + +singleton attestation_key_backup { + gen_fieldname: "attestationKeyBackupConfig" + gen_typename: "AttestationKeyBackupConfig" + gen_setters: false + user_def_ok: true + doc: """ +Attestation key provider which embeds the signing keys into chalk binary. +For easy signing, however, the password is retrieved on-demand +from backup service when when needing to sign artifacts. + +Any signing keys generated or imported are encrypted by a randomly generated +password (which is derived by encoding 128 bits taken from a cryptographically +secure source). + +When using the backup service, the password is encrypted by another randomly +generated value, stored in your binary. The result is posted to our free +service over TLS; the service will then be queried as it is needed. +""" + + field location { + type: string + default: "./chalk.key" + validator: func validate_key_path + shortdoc: "Signing key location" + doc: """ +This is only used for the `chalk setup` command; it dictates where to +either find a key pair to load, or where to write a keypair being +generated. + +Chalk will also embed the keypairs internally, for future operations. +""" + } + + field uri { + type: string + default: "https://chalk.crashoverride.run/v0.1/key-backup" + shortdoc: "URL of the signing key backup API service" + doc: """ +This is the URL of the default signing key backup service provide by Crashoverride. +""" + } + + field auth { + type: string + default: "crashoverride" + shortdoc: "Authentication config to use for the signing key backup service" + doc: """ +Authentication config as configured via `auth_config` to use for +making requests to fetch the password. +""" + } + + field timeout { + type: Duration + default: << 3 sec >> + shortdoc: "HTTPS timeout for request to the key backup service" + doc: """ +If the timeout is exceeded and the operation fails, chalk will proceed, +just without doing any signing / verifying. +""" + } + +} + +singleton attestation_key_get { + gen_fieldname: "attestationKeyGetConfig" + gen_typename: "AttestationKeyGetConfig" + gen_setters: false + user_def_ok: true + doc: """ +Attestation key provider which gets the keys via HTTTP GET request. +Service must return a JSON response of the form: + +``` +{ + "password": "...", + "publicKey": "...", + "privateKey": "..." +} +``` + +Normally during `chalk setup` it requires all 3 fields to be returned +by the API in order to embed the keys into chalk binary. +After that point, as only the password is necessary, in order to avoid +sending full keys on the wire, the endpoint can support `?only=password` +query string which can return only the `password` JSON field. +""" + + field uri { + type: string + default: "https://chalk.crashoverride.run/v0.1/key-provider/keys" + hidden: true + shortdoc: "URL of the signing key provider API service" + doc: """ +By default signing key provider service provide by Crashoverride is used. +""" + } + + field auth { + type: string + default: "crashoverride" + hidden: true + shortdoc: "Authentication config to use for the signing key provider service" + doc: """ +Authentication config as configured via `auth_config` to use for +making requests to fetch the key. +""" + } + + field timeout { + type: Duration + default: << 3 sec >> + shortdoc: "HTTPS timeout for request to the key provider service" + doc: """ +If the timeout is exceeded and the operation fails, chalk will proceed, +just without doing any signing / verifying. +""" + } +} + +attestation_key_types := ["embed", "backup", "get"] + +singleton attestation { + gen_fieldname: "attestationConfig" + gen_typename: "AttestationConfig" + gen_setters: false + user_def_ok: true + doc: """ +TODO +""" + allow attestation_key_embed + allow attestation_key_backup + allow attestation_key_get + + field key_provider { + type: string + choice: attestation_key_types + default: "embed" + doc: "What provider to use for fetching signing key" + } + +} + object outconf { gen_fieldname: "outputConfigs" gen_typename: "OutputConfig" @@ -2330,6 +2502,7 @@ root { allow sink_config allow auth allow auth_config + allow attestation allow mark_template allow report_template allow outconf @@ -2674,87 +2847,6 @@ validate in the transparency log. """ } - field use_signing_key_backup_service { - type: bool - default: false - shortdoc: "Use signing key backup service to save & retrieve keys" - doc: """ -Any signing keys generated or imported are encrypted by a randomly -generated password (which is derived by encoding 128 bits taken from a -cryptographically secure source). - -The password is NOT stored in the binary. You can choose to provide -the password via the CHALK_PASSWORD environment variable, or you may -escrow it in our backup service. - -If this is true, when you set up signing, the password will get -encrypted by another randomly generated value, stored in your -binary. The result will posted to our free service over TLS; the -service will then be queried as it is needed. - -If you do not select this when setting up signing, the password will -be written to stdout a single time. Or, you can set the password -manually via the CHALK_PASSWORD environment variable (if using the -manager, the env variable is ignored during setup). - -Once you have configured signing, Chalk will first try to read the -password from the CHALK_PASSWORD environment variable. If the -environment variable doesn't exist, what happens next is dependant on -this variable. - -If it's true, then we attempt to query the key backup service before -giving up. If it's false, we immediately move on without signing. - -Note that if you provide the environment variable, then the key backup -service currently is not queried. We may eventually make it a fallback. -""" - } - - field signing_key_backup_service_url { - type: string - default: "https://chalk.crashoverride.run/v0.1/key-backup" - hidden: true - shortdoc: "URL of the signing key backup API service" - doc: """ -This is the URL of the default signing key backup service provide by -Crashoverride. -""" - } - -field signing_key_backup_service_auth_config_name { - type: string - default: "crashoverride" - hidden: true - shortdoc: "Authentication config to use for the signing key backup service" - doc: """ - -""" - } - - field signing_key_backup_service_timeout { - type: Duration - default: << 3 sec >> - shortdoc: "HTTPS timeout for request to the key backup service" - doc: """ -If the timeout is exceeded and the operation fails, chalk will proceed, -just without doing any signing / verifying. -""" - } - - field signing_key_location { - type: string - default: "./chalk.key" - shortdoc: "Signing key location" - doc: """ - -This is only used for the `chalk setup` command; it dictates where to -either find a key pair to load, or where to write a keypair being -generated. - -Chalk will also embed the keypairs internally, for future operations. -""" -} - field ignore_patterns { type: list[string] default: [".*/\\..*", ".*\\.txt", ".*\\.json"] @@ -3141,6 +3233,17 @@ func keyspec_exists(name) { return false } +func validate_key_path(path, template_name) { + if not ends_with(path, ".key") or not ends_with(path, ".pub") { + return "Key path must use either .key or .pub extension" + } + parts := path_split(path) + if len(parts[1]) <= 5 { + return "Key path must define a filename" + } + return "" +} + func validate_probability(name, value) { if value <= 0 or value > 100 { return "Probability must be an int greater than 0, but less than or equal to 100" diff --git a/src/configs/getopts.c4m b/src/configs/getopts.c4m index b4150efb..f3a15673 100644 --- a/src/configs/getopts.c4m +++ b/src/configs/getopts.c4m @@ -635,71 +635,28 @@ Set up code signing / attestation.

This is used to either generate or load a keypair for code signing.

-Without a subcommand, chalk will first attempt to run as if the `load` -subcommand were given, and if it doesn't find a keypair to load, then -it will run the `gen` command to create one. -""" - flag_yn store_password { - field_to_set: "use_internal_password" - doc: """ -Sets whether to cache an encrypted copy of the password inside the -chalk binary. This requires good operational security on the -binary, so using a secret manager to set the password is ideal, but -this option is better than hardcoding the password in a script that -calls Chalk. -""" - } +Attestation signing keys consists of: - flag_arg key_file { - field_to_set: "signing_key_location" - doc: """ -Sets the location of the signing key file on local disk. This should -point to the private key file and end in `.key`. The public key file -must be in the same base location, with the same file name, except -with `.pub` as the file extension. -""" - } +* encrypted private key +* public key +* password to decrypt private key - command gen { - shortdoc: "Force generation of new signing key" - doc: """ -Chalk will generate a new -password and keypair. It will write the password to the terminal a -single time, and will write the keypair to `./chalk.key` (private key) -and `./chalk.pub` (public key). -

-The password is needed to load the keys into a different chalk binary, -and is required for signing. By default, you must provide the password -in an environment variable named `CHALK_PASSWORD`. Be careful to -remove it from the environment after your chalk invocation, to avoid -leakage. -

-Generally, that option is intended for running with a secrets -manager. However, if you are going to hardcode the password and rely -on operational security, you can hardcode the password directly into -the Chalk binary with the `--store-password` option. -

-That will store the password inside the chalk mark, encrypted with a -key that is chosen at `gen` time (on a Mac this is not possible; it's -chosen when you compile the binary). -

-This approach will NOT thwart someone who has read access to the -binary, so a proper secret manager is the recommended solution. -""" - } +Key provider as configured in `attestation.key_provider` is used to load or +generate new keys. Keys are embedded into chalk binary itself however password +is never stored in chalk binary. As such depending on provider password might +need to be provided to chalk to successfully sign artifacts at a later time via +`CHALK_PASSWORD` environment variable or provider will automatically retrieve +the password. If using environment variable be careful to remove it from the +environment after your chalk invocation, to avoid leakage. - command load { - shortdoc: "Load existing signing key" - doc: """ -This looks for a keypair to import, by default in ./chalk.key (private -key) and ./chalk.pub (public key). The key should have previously -been generated by chalk, and encrypted with a password. -

-Pass `--store-password` to keep an encrypted copy of the password -internally, per the `gen` command. +Chalk will first attempt to load existing keys, if present, else will generate +new keys as per key provider configuration. Note that some providers have +nested configuration values such as where to place generated keys on disk. By +default they will write key to `./chalk.key` (private key) and `./chalk.pub` +(public key) files. To see how else providers can be configured see provider +configuration help under `attestation_key_`. """ - } } diff --git a/src/plugins/codecDocker.nim b/src/plugins/codecDocker.nim index 4ddc096c..0e4a6771 100644 --- a/src/plugins/codecDocker.nim +++ b/src/plugins/codecDocker.nim @@ -4,7 +4,7 @@ ## This file is part of Chalk ## (see https://crashoverride.com/docs/chalk) ## -import ".."/[config, docker_base, chalkjson, attestation, plugin_api, util] +import ".."/[config, docker_base, chalkjson, attestation_api, plugin_api, util] const markFile = "chalk.json" diff --git a/src/plugins/system.nim b/src/plugins/system.nim index c7abd2d8..86dd6e28 100644 --- a/src/plugins/system.nim +++ b/src/plugins/system.nim @@ -12,7 +12,7 @@ when defined(posix): import std/posix_utils import std/[monotimes, nativesockets, sequtils, times] -import ".."/[config, plugin_api, normalize, chalkjson, selfextract, attestation, +import ".."/[config, plugin_api, normalize, chalkjson, attestation_api, util] var diff --git a/tests/chalk/runner.py b/tests/chalk/runner.py index 5ae64850..30edb357 100644 --- a/tests/chalk/runner.py +++ b/tests/chalk/runner.py @@ -327,7 +327,9 @@ def extract( artifact: Path | str, expected_success: bool = True, ignore_errors: bool = False, + config: Optional[Path] = None, log_level: ChalkLogLevel = "error", + env: Optional[dict[str, str]] = None, ) -> ChalkProgram: return self.run( command="extract", @@ -335,6 +337,8 @@ def extract( log_level=log_level, expected_success=expected_success, ignore_errors=ignore_errors, + config=config, + env=env, ) def exec(self, artifact: Path, as_parent: bool = False) -> ChalkProgram: diff --git a/tests/conftest.py b/tests/conftest.py index 3dc925f6..f97df078 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,6 @@ # # This file is part of Chalk # (see https://crashoverride.com/docs/chalk) -import re import shutil import sqlite3 from contextlib import ExitStack, chdir, closing diff --git a/tests/data/configs/attestation/backup.c4m b/tests/data/configs/attestation/backup.c4m new file mode 100644 index 00000000..6f0be4d4 --- /dev/null +++ b/tests/data/configs/attestation/backup.c4m @@ -0,0 +1,12 @@ +auth_config test { + auth: "jwt" + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +} + +attestation { + key_provider: "backup" + attestation_key_backup { + uri: env("CHALK_BACKUP_URL") + auth: "test" + } +} diff --git a/tests/data/configs/attestation/embed.c4m b/tests/data/configs/attestation/embed.c4m new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/configs/attestation/get.c4m b/tests/data/configs/attestation/get.c4m new file mode 100644 index 00000000..8b96192a --- /dev/null +++ b/tests/data/configs/attestation/get.c4m @@ -0,0 +1,12 @@ +auth_config test { + auth: "jwt" + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" +} + +attestation { + key_provider: "get" + attestation_key_get { + uri: env("CHALK_GET_URL") + auth: "test" + } +} diff --git a/tests/data/configs/nosigningkeybackup.c4m b/tests/data/configs/nosigningkeybackup.c4m deleted file mode 100644 index 2b45d70e..00000000 --- a/tests/data/configs/nosigningkeybackup.c4m +++ /dev/null @@ -1 +0,0 @@ -use_signing_key_backup_service: false diff --git a/tests/test_command.py b/tests/test_command.py index 2173e9ea..2302b4fd 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -178,14 +178,30 @@ def test_env(chalk: Chalk): assert run(["uname", "-n"]).text in report["_OP_NODENAME"] -def test_setup(chalk_copy: Chalk): +@pytest.mark.parametrize("copy_files", [[LS_PATH]], indirect=True) +@pytest.mark.parametrize( + "config", + [ + CONFIGS / "attestation" / "embed.c4m", + CONFIGS / "attestation" / "backup.c4m", + CONFIGS / "attestation" / "get.c4m", + ], +) +def test_setup( + copy_files: list[Path], + chalk_copy: Chalk, + config: Path, + server_http: str, +): """ - needs to display password, and public and private key info in chalk + check that after setup attestion works for all key providers """ - setup = chalk_copy.run( - command="setup", - config=CONFIGS / "nosigningkeybackup.c4m", - ) + env = { + "CHALK_BACKUP_URL": f"{server_http}/backup", + "CHALK_GET_URL": f"{server_http}/cosign", + } + chalk_copy.load(config, replace=False) + setup = chalk_copy.run(command="setup", env=env) assert setup.mark.contains( { "$CHALK_PUBLIC_KEY": re.compile(r"^-----BEGIN PUBLIC KEY"), @@ -197,30 +213,61 @@ def test_setup(chalk_copy: Chalk): } ) + if "CHALK_PASSWORD" in setup.text: + env["CHALK_PASSWORD"] = setup.find("CHALK_PASSWORD").split("=", 1)[1] + + insert = chalk_copy.insert(copy_files[0], env=env) + assert insert.mark.contains( + { + "INJECTOR_PUBLIC_KEY": setup.mark["$CHALK_PUBLIC_KEY"], + "SIGNATURE": ANY, + } + ) + + extract = chalk_copy.extract(copy_files[0], env=env) + assert extract.mark.contains( + { + "INJECTOR_PUBLIC_KEY": setup.mark["$CHALK_PUBLIC_KEY"], + "SIGNATURE": insert.mark["SIGNATURE"], + "_VALIDATED_SIGNATURE": True, + } + ) + +@pytest.mark.parametrize( + "config", + [ + CONFIGS / "attestation" / "embed.c4m", + CONFIGS / "attestation" / "backup.c4m", + # note get provider does not support reading existing key + ], +) @pytest.mark.parametrize("copy_files", [[LS_PATH]], indirect=True) def test_setup_existing_keys( tmp_data_dir: Path, chalk_copy: Chalk, random_hex: str, copy_files: list[Path], + config: Path, + server_http: str, ): """ needs to display password, and public and private key info in chalk """ + password = (random_hex * 3)[:24] # at least 24 bytes are required for PRP assert run( ["cosign", "generate-key-pair", "--output-key-prefix", "chalk"], - env={"COSIGN_PASSWORD": random_hex}, + env={"COSIGN_PASSWORD": password}, ) public = (tmp_data_dir / "chalk.pub").read_text() private = (tmp_data_dir / "chalk.key").read_text() + env = { + "CHALK_PASSWORD": password, + "CHALK_BACKUP_URL": f"{server_http}/backup", + } - setup = chalk_copy.run( - command="setup", - config=CONFIGS / "nosigningkeybackup.c4m", - env={"CHALK_PASSWORD": random_hex}, - ) - + chalk_copy.load(config, replace=False) + setup = chalk_copy.run(command="setup", env=env) assert setup.mark.contains( { "$CHALK_PUBLIC_KEY": public, @@ -230,15 +277,19 @@ def test_setup_existing_keys( } ) - insert = chalk_copy.insert( - copy_files[0], - env={"CHALK_PASSWORD": random_hex}, - ) - assert insert.mark.has( - SIGNATURE=ANY, + insert = chalk_copy.insert(copy_files[0], env=env) + assert insert.mark.contains( + { + "INJECTOR_PUBLIC_KEY": setup.mark["$CHALK_PUBLIC_KEY"], + "SIGNATURE": ANY, + } ) - extract = chalk_copy.extract(copy_files[0]) - assert extract.mark.has( - _VALIDATED_SIGNATURE=True, + extract = chalk_copy.extract(copy_files[0], env=env) + assert extract.mark.contains( + { + "INJECTOR_PUBLIC_KEY": setup.mark["$CHALK_PUBLIC_KEY"], + "SIGNATURE": insert.mark["SIGNATURE"], + "_VALIDATED_SIGNATURE": True, + } ) diff --git a/tests/testing.c4m b/tests/testing.c4m index c1d9601d..9441a41d 100644 --- a/tests/testing.c4m +++ b/tests/testing.c4m @@ -1,5 +1,7 @@ subscribe("report", "json_console_out") custom_report.github_group_chalk_time.enabled: false +custom_report.terminal_chalk_time.enabled: false +custom_report.terminal_other_op.enabled: false # ignore any cloud metadata by default # as github actions use azure runners