5 Commits

Author SHA1 Message Date
renovate[bot]
ea10a33de3 fix(deps): update github.com/kairos-io/tpm-helpers digest to e914e08 (#145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 07:02:12 +00:00
renovate[bot]
e594366653 fix(deps): update module github.com/mudler/yip to v1.18.1 (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 03:45:26 +00:00
renovate[bot]
ef5f5dde86 chore(deps): update google/osv-scanner-action action to v2.2.3 (#147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 03:31:38 +00:00
renovate[bot]
2800b85965 chore(deps): update github/codeql-action action to v4 (#150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 03:15:33 +00:00
renovate[bot]
905c02ab8d fix(deps): update module github.com/onsi/ginkgo/v2 to v2.26.0 (#148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 23:28:34 +00:00
41 changed files with 1505 additions and 5538 deletions

View File

@@ -55,15 +55,12 @@ jobs:
fail-fast: false
matrix:
include:
# Basic encryption tests
- label: "local-encryption"
- label: "remote-auto"
- label: "remote-static"
- label: "remote-https-pinned"
- label: "remote-https-bad-cert"
- label: "discoverable-kms"
# Consolidated remote attestation workflow test
- label: "remote-complete-workflow"
steps:
- name: Checkout code
uses: actions/checkout@v5
@@ -93,12 +90,10 @@ jobs:
env:
LABEL: ${{ matrix.label }}
KVM: true
CGO_ENABLED: 1
run: |
sudo apt update && \
sudo apt install -y git qemu-system-x86 qemu-utils swtpm jq make glibc-tools \
openssl curl gettext ca-certificates curl gnupg lsb-release build-essential \
libssl-dev
openssl curl gettext ca-certificates curl gnupg lsb-release
export ISO=$PWD/$(ls *.iso)
# update controllers

View File

@@ -18,4 +18,4 @@ permissions:
jobs:
scan-pr:
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.2.2"
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v2.2.3"

View File

@@ -26,7 +26,7 @@ jobs:
# we let the report trigger content trigger a failure using the GitHub Security features.
args: '-no-fail -fmt sarif -out results.sarif ./...'
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
# Path to SARIF file relative to the root of the repository
sarif_file: results.sarif

1
.gitignore vendored
View File

@@ -6,7 +6,6 @@
*.dylib
bin
testbin/*
manager
# Test binary, build with `go test -c`
*.test

View File

@@ -1,11 +1,8 @@
VERSION 0.6
# renovate: datasource=github-releases depName=kairos-io/kairos
ARG KAIROS_VERSION="v3.5.3"
ARG KAIROS_INIT_VERSION="v0.5.20"
ARG UBUNTU_VERSION="24.04"
# Use our custom base image instead of the upstream one
ARG BASE_IMAGE=+custom-kairos-base
ARG KAIROS_VERSION="v2.5.0"
ARG BASE_IMAGE=quay.io/kairos/ubuntu:23.10-core-amd64-generic-$KAIROS_VERSION
ARG OSBUILDER_IMAGE=quay.io/kairos/osbuilder-tools
# renovate: datasource=docker depName=golang
@@ -16,57 +13,13 @@ build-challenger:
FROM +go-deps
COPY . /work
WORKDIR /work
RUN CGO_ENABLED=1 go build -o kcrypt-discovery-challenger ./cmd/discovery
RUN CGO_ENABLED=0 go build -o kcrypt-discovery-challenger ./cmd/discovery
SAVE ARTIFACT /work/kcrypt-discovery-challenger kcrypt-discovery-challenger AS LOCAL kcrypt-discovery-challenger
kairos-init-binary:
ARG KAIROS_INIT_VERSION
FROM quay.io/kairos/kairos-init:${KAIROS_INIT_VERSION}
SAVE ARTIFACT /kairos-init kairos-init
custom-kairos-base:
ARG UBUNTU_VERSION
ARG KAIROS_INIT_VERSION
FROM ubuntu:${UBUNTU_VERSION}
# Copy kairos-init from the kairos-init target
COPY +kairos-init-binary/kairos-init /kairos-init
# STAGE 1: Run kairos-init INSTALL stage only (installs packages, kernel, etc.)
# This will install the default immucore and kairos-agent from packages
RUN /kairos-init -l debug -m "generic" -t "false" -s "install" --version "${KAIROS_INIT_VERSION}"
# STAGE 1.5: Replace the installed binaries with our custom versions
# This happens AFTER package installation but BEFORE initramfs generation
COPY immucore /usr/bin/immucore
COPY kairos-agent /usr/bin/kairos-agent
# Verify our custom binaries are in place
RUN echo "Custom immucore version:" && /usr/bin/immucore --version && \
echo "Custom kairos-agent in place"
# STAGE 2: Run kairos-init INIT stage (generates initramfs with our custom binaries)
RUN /kairos-init -l debug -m "generic" -t "false" -s "init" --version "${KAIROS_INIT_VERSION}" && \
/kairos-init validate -t "false"
# Verify the initramfs was created
RUN ls -lh /boot/initrd && echo "Custom Kairos base image built successfully with custom binaries"
SAVE IMAGE custom-kairos-base:latest
image:
FROM +custom-kairos-base
FROM $BASE_IMAGE
ARG IMAGE
COPY +build-challenger/kcrypt-discovery-challenger /system/discovery/kcrypt-discovery-challenger
# No need to copy binaries or regenerate initramfs - already done in custom-kairos-base!
# Verify our custom immucore is in place
RUN echo "Final immucore version in image:" && /usr/bin/immucore --version
# Add hardcoded root password for debugging
RUN echo 'root:root' | chpasswd
SAVE IMAGE $IMAGE
image-rootfs:
@@ -87,8 +40,6 @@ go-deps:
ARG GO_VERSION
FROM golang:$GO_VERSION
WORKDIR /build
# Install OpenSSL development libraries needed for TPM simulator
RUN apt-get update && apt-get install -y libssl-dev && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
RUN go mod verify
@@ -97,7 +48,7 @@ go-deps:
test:
FROM +go-deps
ENV CGO_ENABLED=1
ENV CGO_ENABLED=0
WORKDIR /work
COPY . .

475
README.md
View File

@@ -27,7 +27,7 @@ With Kairos you can build immutable, bootable Kubernetes and OS images for your
<tr>
<th align="center">
<img width="640" height="1px">
<p>
<p>
<small>
Documentation
</small>
@@ -35,7 +35,7 @@ Documentation
</th>
<th align="center">
<img width="640" height="1">
<p>
<p>
<small>
Contribute
</small>
@@ -46,12 +46,12 @@ Contribute
<td>
📚 [Getting started with Kairos](https://kairos.io/docs/getting-started) <br> :bulb: [Examples](https://kairos.io/docs/examples) <br> :movie_camera: [Video](https://kairos.io/docs/media/) <br> :open_hands:[Engage with the Community](https://kairos.io/community/)
</td>
<td>
🙌[ CONTRIBUTING.md ]( https://github.com/kairos-io/kairos/blob/master/CONTRIBUTING.md ) <br> :raising_hand: [ GOVERNANCE ]( https://github.com/kairos-io/kairos/blob/master/GOVERNANCE.md ) <br>:construction_worker:[Code of conduct](https://github.com/kairos-io/kairos/blob/master/CODE_OF_CONDUCT.md)
🙌[ CONTRIBUTING.md ]( https://github.com/kairos-io/kairos/blob/master/CONTRIBUTING.md ) <br> :raising_hand: [ GOVERNANCE ]( https://github.com/kairos-io/kairos/blob/master/GOVERNANCE.md ) <br>:construction_worker:[Code of conduct](https://github.com/kairos-io/kairos/blob/master/CODE_OF_CONDUCT.md)
</td>
</tr>
</table>
@@ -59,39 +59,12 @@ Contribute
| :exclamation: | This is experimental! |
|-|:-|
This is the Kairos kcrypt-challenger Kubernetes Native Extension.
This is the Kairos kcrypt-challenger Kubernetes Native Extension.
## Usage
See the documentation in our website: https://kairos.io/docs/advanced/partition_encryption/.
### TPM NV Memory Cleanup
⚠️ **DANGER**: This command removes encryption passphrases from TPM memory!
⚠️ **If you delete the wrong index, your encrypted disk may become UNBOOTABLE!**
During development and testing, the kcrypt-challenger may store passphrases in TPM non-volatile (NV) memory. These passphrases persist across reboots and can accumulate over time, taking up space in the TPM.
To clean up TPM NV memory used by the challenger:
```bash
# Clean up the default NV index (respects config or defaults to 0x1500000)
kcrypt-discovery-challenger cleanup
# Clean up a specific NV index
kcrypt-discovery-challenger cleanup --nv-index=0x1500001
# Clean up with specific TPM device
kcrypt-discovery-challenger cleanup --tpm-device=/dev/tpmrm0
```
**Safety Features:**
- By default, the command shows warnings and prompts for confirmation
- You must type "yes" to proceed with deletion
- Use `--i-know-what-i-am-doing` flag to skip the prompt (not recommended)
**Note**: This command uses native Go TPM libraries and requires appropriate permissions to access the TPM device.
## Installation
To install, use helm:
@@ -100,7 +73,7 @@ To install, use helm:
# Adds the kairos repo to helm
$ helm repo add kairos https://kairos-io.github.io/helm-charts
"kairos" has been added to your repositories
$ helm repo update
$ helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "kairos" chart repository
Update Complete. ⎈Happy Helming!⎈
@@ -117,435 +90,3 @@ TEST SUITE: None
# Installs challenger
$ helm install kairos-challenger kairos/kcrypt-challenger
```
## Remote Attestation Flow
The kcrypt-challenger implements a secure TPM-based remote attestation flow for disk encryption key management. The following diagram illustrates the complete attestation process:
```mermaid
sequenceDiagram
participant TPM as TPM Hardware
participant Client as TPM Client<br/>(Kairos Node)
participant Challenger as Kcrypt Challenger<br/>(Server)
participant K8s as Kubernetes API<br/>(SealedVolume/Secret)
Note over TPM,Client: Client Boot Process
Client->>TPM: Extract EK (Endorsement Key)
Client->>TPM: Generate Transient AK (Ephemeral Attestation Key)
Client->>TPM: Read PCR Values (Boot State)
Note over Client,Challenger: 1. Connection Establishment
Client->>Challenger: WebSocket connection with partition info<br/>(label, device, UUID)
Challenger->>Client: Connection established
Note over Client,Challenger: 2. TPM Authentication (Challenge-Response)
Client->>Challenger: Send EK + Transient AK attestation data
Challenger->>Challenger: Decode EK/AK, compute TPM hash
Challenger->>Challenger: Generate cryptographic challenge
Challenger->>Client: Send challenge (encrypted with EK)
Client->>TPM: Decrypt challenge using private EK
Client->>TPM: Sign response using transient AK
Client->>Challenger: Send proof response + PCR quote
Challenger->>Challenger: Verify challenge response
Note over Challenger,K8s: 3. Enrollment Context Determination
Challenger->>K8s: List SealedVolumes by TPM hash
K8s->>Challenger: Return existing volumes (if any)
alt New Enrollment (TOFU - Trust On First Use)
Note over Challenger,K8s: 4a. Initial TOFU Enrollment
Challenger->>Challenger: Skip attestation verification (TOFU)
Challenger->>Challenger: Generate secure passphrase
Challenger->>K8s: Create/reuse Kubernetes Secret
Challenger->>Challenger: Create attestation spec (store ALL PCRs)
Challenger->>K8s: Create SealedVolume with attestation data
K8s->>Challenger: Confirm resource creation
else Existing Enrollment
Note over Challenger,K8s: 4b. Selective Verification & Re-enrollment
Challenger->>Challenger: Check if TPM is quarantined
alt TPM Quarantined
Challenger->>Client: Security rejection (access denied)
else TPM Not Quarantined
Note over Challenger: Selective Attestation Verification
Challenger->>Challenger: Verify AK using selective enrollment:<br/>• Empty AK = re-enrollment mode (accept any)<br/>• Set AK = enforcement mode (exact match)
Challenger->>Challenger: Verify PCRs using selective enrollment:<br/>• Empty PCR = re-enrollment mode (accept + update)<br/>• Set PCR = enforcement mode (exact match)<br/>• Omitted PCR = skip verification entirely
alt Verification Failed
Challenger->>Client: Security rejection (attestation failed)
else Verification Passed
Challenger->>Challenger: Update empty fields with current values
Challenger->>K8s: Update SealedVolume (if changes made)
end
end
end
Note over Challenger,K8s: 5. Passphrase Retrieval & Delivery
Challenger->>K8s: Get Kubernetes Secret by name/path
K8s->>Challenger: Return encrypted passphrase
Challenger->>Client: Send passphrase securely
Note over TPM,Client: 6. Disk Decryption
Client->>Client: Use passphrase to decrypt disk partition
Client->>Challenger: Close WebSocket connection
Note over TPM,Client: Success - Node continues boot process
```
### Transient Attestation Key (AK) Approach
The kcrypt-challenger now uses a **transient AK approach** that eliminates the need for persistent AK storage:
- **No Persistent Storage**: AKs are created fresh for each attestation request
- **EK-Only Enrollment**: Only the Endorsement Key (EK) is enrolled and stored on the server
- **Ephemeral AKs**: Each boot generates a new transient AK for attestation
- **Reduced TPM Usage**: No persistent TPM resources are consumed for AK storage
- **Simplified Management**: No need to manage AK lifecycle or cleanup
### Flow Explanation
1. **Connection Establishment**: Client establishes WebSocket connection with partition metadata
2. **TPM Authentication**: Cryptographic challenge-response proves client controls the TPM hardware
3. **Enrollment Determination**: Server checks if this TPM is already enrolled
4. **Security Verification**:
- **TOFU**: New TPMs are automatically enrolled (Trust On First Use)
- **Selective Enrollment**: Existing TPMs undergo flexible verification based on field states
5. **Passphrase Delivery**: Encrypted disk passphrase is securely delivered to authenticated client
### Selective Enrollment States
| Field State | Verification | Updates | Use Case |
|-------------|-------------|---------|----------|
| **Empty** (`""`) | ✅ Accept any value | ✅ Update with current | Re-learn after TPM/firmware changes |
| **Set** (`"abc123"`) | ✅ Enforce exact match | ❌ No updates | Strict security enforcement |
| **Omitted** (deleted) | ❌ Skip entirely | ❌ Never re-enrolled | Ignore volatile PCRs (e.g., PCR 11) |
## Selective Enrollment Mode for TPM Attestation
The kcrypt-challenger implements a sophisticated "selective enrollment mode" that solves operational challenges in real-world TPM-based disk encryption deployments. This feature provides flexible attestation management while maintaining strong security guarantees.
### Key Features
- Full selective enrollment with three field states (empty, set, omitted)
- Trust On First Use (TOFU) automatic enrollment
- Secret reuse after SealedVolume recreation
- PCR re-enrollment for kernel upgrades
- PCR omission for volatile boot stages
- Early quarantine checking with fail-fast behavior
### How Selective Enrollment Works
The system supports two distinct enrollment behaviors:
#### **Initial TOFU Enrollment** (No SealedVolume exists)
- **Store ALL PCRs** provided by the client (don't omit any)
- Create complete attestation baseline from first contact
- Enables full security verification for subsequent attestations
#### **Selective Re-enrollment** (SealedVolume exists with specific fields)
- **Empty values** (`""`) = Accept any value, update the stored value (re-enrollment mode)
- **Set values** (`"abc123..."`) = Enforce exact match (enforcement mode)
- **Omitted fields** = Skip verification entirely (ignored mode)
**Selective Enrollment Behavior Summary:**
| Field State | Verification | Updates | Use Case |
|-------------|-------------|---------|----------|
| **Empty** (`""`) | ✅ Accept any value | ✅ Update with current | Re-learn after TPM/firmware changes |
| **Set** (`"abc123"`) | ✅ Enforce exact match | ❌ No updates | Strict security enforcement |
| **Omitted** (deleted) | ❌ Skip entirely | ❌ Never re-enrolled | Ignore volatile PCRs (e.g., PCR 11) |
### SealedVolume API Examples
#### **Attestation Configuration Options Summary**
| Configuration | EK Behavior | PCR Behavior | Use Case |
|--------------|-------------|--------------|----------|
| **No `spec.attestation`** | Learn all | Learn all PCRs | Pure TOFU / Static passphrase setup |
| **`attestation: {}`** | Learn EK, enforce | Skip all PCRs | EK-only verification (no boot state) |
| **`attestation: { ekPublicKey: "", pcrValues: { pcrs: { "0": "", "7": "" } } }`** | Learn EK, enforce | Learn & enforce PCR 0, 7 only | Selective PCR tracking |
| **`attestation: { ekPublicKey: "abc...", pcrValues: { pcrs: { "0": "def..." } } }`** | Enforce exact match | Enforce exact PCR 0 | Full enforcement mode |
**Key Points:**
- `nil` (omitted field) = Learn everything via TOFU
- Empty object `{}` or empty string `""` = Learn on first use, then enforce
- Set value `"abc123..."` = Strict enforcement (exact match required)
- Omitted from map = Skip entirely (never verify, never store)
#### **Example 1: Initial TOFU Enrollment**
When no SealedVolume exists, the server automatically creates one with ALL received PCRs:
```yaml
# Server creates this automatically during TOFU enrollment
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
spec:
TPMHash: "computed-from-client"
attestation:
ekPublicKey: "learned-ek" # Learned from client
akPublicKey: "learned-ak" # Learned from client
pcrValues:
pcrs:
"0": "abc123..." # All received PCRs stored
"7": "def456..."
"11": "ghi789..." # Including PCR 11 if provided
```
#### **Example 2: Selective Re-enrollment Control**
Operators can control which fields allow re-enrollment:
```yaml
# Operator-controlled selective enforcement
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
spec:
TPMHash: "required-tpm-hash" # MUST be set for client matching
attestation:
ekPublicKey: "" # Empty = re-enrollment mode
akPublicKey: "fixed-ak" # Set = enforce this value
pcrValues:
pcrs:
"0": "" # Empty = re-enrollment mode
"7": "fixed-value" # Set = enforce this value
# "11": omitted # Omitted = skip entirely
```
### Use Cases Solved
1. **Pure TOFU**: No SealedVolume exists → System learns ALL attestation data from first contact
2. **Static Passphrase Tests**: Create Secret + SealedVolume with TPM hash, let TOFU handle attestation data
3. **Production Manual Setup**: Operators set known passphrases + TPM hashes, system learns remaining security data
4. **Firmware Upgrades**: Set PCR 0 to empty to re-learn after BIOS updates
5. **TPM Replacement**: Set AK/EK fields to empty to re-learn after hardware changes
6. **Flexible Boot Stages**: Omit PCR 11 entirely so users can decrypt during boot AND after full system startup
7. **Kernel Updates**: Omit PCR 11 to avoid quarantine on routine Kairos upgrades
### Practical Operator Workflows
#### **Scenario 1: Reusing Existing Passphrases After SealedVolume Recreation**
**Problem**: An operator needs to recreate a SealedVolume (e.g., after accidental deletion or configuration changes) but wants to keep using the existing passphrase to avoid re-encrypting the disk.
**Solution**: The system automatically reuses existing Kubernetes secrets when available:
```bash
# 1. Operator accidentally deletes SealedVolume
kubectl delete sealedvolume my-encrypted-volume
# 2. Original secret still exists in cluster
kubectl get secret my-encrypted-volume-encrypted-data
# NAME TYPE DATA AGE
# my-encrypted-volume-encrypted-data Opaque 1 5d
# 3. When TPM client reconnects, system detects existing secret
# and reuses the passphrase instead of generating a new one
```
**Behavior**: The system will:
- Detect the existing secret with the same name
- Log: "Secret already exists, reusing existing secret"
- Use the existing passphrase for decryption
- Recreate the SealedVolume with current TPM attestation data
- Maintain continuity without requiring disk re-encryption
#### **Scenario 2: Deliberately Skipping PCRs After Initial Enrollment**
**Problem**: An operator initially enrolls with PCRs 0, 7, and 11, but later realizes PCR 11 changes frequently due to kernel updates and wants to ignore it permanently.
**Solution**: Remove the PCR from the SealedVolume specification:
```bash
# 1. Initial enrollment created SealedVolume with:
# pcrValues:
# pcrs:
# "0": "abc123..."
# "7": "def456..."
# "11": "ghi789..."
# 2. Operator edits SealedVolume to remove PCR 11 entirely
kubectl edit sealedvolume my-encrypted-volume
# Remove the "11": "ghi789..." line completely
# 3. Result - omitted PCR 11:
# pcrValues:
# pcrs:
# "0": "abc123..."
# "7": "def456..."
# # PCR 11 omitted = ignored entirely
```
**Behavior**: The system will:
- Skip PCR 11 verification entirely (no enforcement)
- Never re-enroll PCR 11 in future attestations
- Log: "PCR verification successful using selective enrollment" (without mentioning PCR 11)
- Continue enforcing PCRs 0 and 7 normally
#### **Scenario 3: Manual PCR Selection During Initial Setup**
**Problem**: An operator knows certain PCRs will be unstable and wants to exclude them from the beginning.
**Solution**: Create the initial SealedVolume manually with only desired PCRs:
```yaml
# Create SealedVolume with selective PCR enforcement from the start
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: selective-pcr-volume
spec:
TPMHash: "known-tpm-hash"
partitions:
- label: "encrypted-data"
secret:
name: "my-passphrase"
path: "passphrase"
attestation:
ekPublicKey: "" # Re-enrollment mode (will learn EK on first boot)
pcrValues:
pcrs:
"0": "" # Re-enrollment mode (will learn)
"7": "" # Re-enrollment mode (will learn)
# "11": omitted # Skip PCR 11 entirely
```
**Behavior**: The system will:
- Learn and enforce the EK on first attestation
- Learn and enforce PCRs 0 and 7 on first attestation
- Completely ignore PCR 11 (never verify, never store)
- Allow flexible boot stages without PCR 11 interference
**Alternative - Omit ALL PCRs**:
```yaml
# Learn EK only, skip ALL PCR verification
spec:
attestation: {} # Empty object = learn EK, ignore all PCRs
```
**Behavior**: The system will:
- Learn and enforce the EK (TPM identity)
- Accept any PCR values without verification
- Never store or track PCR values
- Useful when boot state verification is not needed
#### **Scenario 4: Kernel Upgrade - Temporary PCR Re-enrollment**
**Problem**: An operator is performing a kernel upgrade and knows PCR 11 will change, but wants to continue enforcing it after the upgrade (unlike permanent omission).
**Solution**: Set the PCR value to empty string to trigger re-enrollment mode:
```bash
# 1. Before kernel upgrade - PCR 11 is currently enforced
kubectl get sealedvolume my-volume -o jsonpath='{.spec.attestation.pcrValues.pcrs.11}'
# Output: "abc123def456..." (current PCR 11 value)
# 2. Set PCR 11 to empty string to allow re-enrollment
kubectl patch sealedvolume my-volume --type='merge' \
-p='{"spec":{"attestation":{"pcrValues":{"pcrs":{"11":""}}}}}'
# 3. Perform kernel upgrade and reboot
# 4. After reboot, TPM client reconnects and system learns new PCR 11 value
# Log will show: "Updated PCR value during selective enrollment, pcr: 11"
# 5. Verify new PCR 11 value is now enforced
kubectl get sealedvolume my-volume -o jsonpath='{.spec.attestation.pcrValues.pcrs.11}'
# Output: "new789xyz012..." (new PCR 11 value after kernel upgrade)
```
**Behavior**: The system will:
- Accept any PCR 11 value on next attestation (re-enrollment mode)
- Update the stored PCR 11 with the new post-upgrade value
- Resume strict PCR 11 enforcement with the new value
- Log: "Updated PCR value during selective enrollment"
**Key Difference from Scenario 2:**
- **Scenario 2 (Omit PCR)**: PCR 11 permanently ignored, never verified again
- **Scenario 4 (Empty PCR)**: PCR 11 temporarily re-enrolled, then enforced with new value
### Security Architecture
- **TPM Hash is mandatory** - prevents multiple clients from matching the same SealedVolume
- **EK verification remains strict** - only AK and PCRs support selective enrollment modes
- **Early quarantine checking** - quarantined TPMs are rejected immediately after authentication
- **Comprehensive logging** - all enrollment events are logged for audit trails
- **Challenge-response authentication** - prevents TPM impersonation attacks
### Quick Reference for Documentation
**Common Operations:**
```bash
# Skip a PCR permanently (never verify again)
kubectl edit sealedvolume my-volume
# Remove the PCR line entirely from pcrValues.pcrs
# Temporarily allow PCR re-enrollment (e.g., before kernel upgrade)
kubectl patch sealedvolume my-volume --type='merge' -p='{"spec":{"attestation":{"pcrValues":{"pcrs":{"11":""}}}}}'
# Re-learn a PCR after hardware change (e.g., PCR 0 after BIOS update)
kubectl patch sealedvolume my-volume --type='merge' -p='{"spec":{"attestation":{"pcrValues":{"pcrs":{"0":""}}}}}'
# Re-learn EK after TPM replacement (transient AK approach)
kubectl patch sealedvolume my-volume --type='merge' -p='{"spec":{"attestation":{"ekPublicKey":""}}}'
# Check current PCR enforcement status
kubectl get sealedvolume my-volume -o jsonpath='{.spec.attestation.pcrValues.pcrs}' | jq .
```
**Log Messages to Expect:**
- `"Secret already exists, reusing existing secret"` - Passphrase reuse scenario
- `"Updated PCR value during selective enrollment"` - Re-enrollment mode active
- `"PCR verification successful using selective enrollment"` - Omitted PCRs ignored
- `"PCR enforcement mode verification passed"` - Strict enforcement active
## ✅ E2E Testing Coverage for Selective Enrollment
### Status: ✅ COMPLETED
Comprehensive E2E test suite has been implemented covering all selective enrollment scenarios. The test suite is optimized for efficiency using VM reuse patterns to minimize execution time while maintaining thorough coverage.
### ✅ Implemented E2E Test Scenarios
#### **Comprehensive Remote Attestation Workflow**
- [x] **Complete E2E Test Suite**: All remote attestation scenarios consolidated into a single comprehensive test (`remote-complete-workflow`)
- TOFU enrollment, quarantine management, PCR management, AK management
- Secret reuse, error handling, multi-partition support
- Performance testing, security verification, and operational workflows
#### **9. Logging & Observability**
- [x] **Audit Trail Verification**: Security events logging validation (integrated across all tests)
- [x] **Log Message Accuracy**: Expected log messages verification (integrated across all tests)
- [x] **Metrics Collection**: Performance monitoring during tests (integrated across all tests)
#### **10. Compatibility Testing**
- [x] **TPM 2.0 Compatibility**: Software TPM emulation with TPM 2.0 (all tests use `swtpm`)
- [x] **Kernel Variations**: PCR behavior testing across different scenarios (`remote-large-pcr`)
- [x] **Hardware Variations**: TPM emulation covering different chip behaviors (via `swtpm`)
### Test Implementation Details
The comprehensive test suite includes:
- **18 Test Labels**: Covering all scenarios from basic to advanced
- **3 Test Files**: Organized by complexity and VM reuse optimization
- **VM Reuse Pattern**: Reduces test time from ~40 minutes to ~20 minutes
- **Real TPM Emulation**: Uses `swtpm` for realistic TPM behavior
- **GitHub Workflow Integration**: All tests run in CI/CD pipeline
See [`tests/README.md`](tests/README.md) for detailed test documentation and usage instructions.
### Test Environment Requirements
- **Real TPM Hardware**: Software TPM simulators may not catch hardware-specific issues
- **Kernel Build Pipeline**: Ability to test actual kernel upgrades and PCR changes
- **Multi-Node Clusters**: Test distributed scenarios and namespace isolation
- **Network Partitioning**: Test resilience under network failures
- **Performance Monitoring**: Metrics collection for scalability validation
### Success Criteria
All E2E tests must pass consistently across:
- Different hardware configurations (various TPM chips)
- Multiple kernel versions (to test PCR 11 variability)
- Various cluster configurations (single-node, multi-node)
- Different load conditions (single client, concurrent clients)
Completing this E2E test suite will provide confidence that the selective enrollment system works reliably in production environments.

View File

@@ -23,38 +23,11 @@ import (
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// PCRValues represents Platform Configuration Register values for boot state verification
// Uses a flexible map where keys are PCR indices (as strings) and values are hex-encoded PCR values
type PCRValues struct {
// PCRs is a flexible map of PCR index (as string) to PCR value (hex-encoded)
// Example: {"0": "a1b2c3...", "7": "d4e5f6...", "11": "g7h8i9..."}
// This allows for any combination of PCRs without hardcoding specific indices
PCRs map[string]string `json:"pcrs,omitempty"`
}
// AttestationSpec defines TPM attestation data for TOFU enrollment and verification
// With transient AK approach, only the EK is stored as the trusted identity
type AttestationSpec struct {
// EKPublicKey stores the Endorsement Key public key in PEM format
// This is the single trusted identity for the TPM
EKPublicKey string `json:"ekPublicKey,omitempty"`
// PCRValues stores the expected PCR values for boot state verification
PCRValues *PCRValues `json:"pcrValues,omitempty"`
// EnrolledAt timestamp when this TPM was first enrolled
EnrolledAt *metav1.Time `json:"enrolledAt,omitempty"`
// LastVerifiedAt timestamp of the last successful attestation
LastVerifiedAt *metav1.Time `json:"lastVerifiedAt,omitempty"`
}
// SealedVolumeSpec defines the desired state of SealedVolume
type SealedVolumeSpec struct {
TPMHash string `json:"TPMHash,omitempty"`
Partitions []PartitionSpec `json:"partitions,omitempty"`
Quarantined bool `json:"quarantined,omitempty"`
Attestation *AttestationSpec `json:"attestation,omitempty"`
TPMHash string `json:"TPMHash,omitempty"`
Partitions []PartitionSpec `json:"partitions,omitempty"`
Quarantined bool `json:"quarantined,omitempty"`
}
// PartitionSpec defines a Partition. A partition can be identified using

View File

@@ -25,56 +25,6 @@ import (
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AttestationSpec) DeepCopyInto(out *AttestationSpec) {
*out = *in
if in.PCRValues != nil {
in, out := &in.PCRValues, &out.PCRValues
*out = new(PCRValues)
(*in).DeepCopyInto(*out)
}
if in.EnrolledAt != nil {
in, out := &in.EnrolledAt, &out.EnrolledAt
*out = (*in).DeepCopy()
}
if in.LastVerifiedAt != nil {
in, out := &in.LastVerifiedAt, &out.LastVerifiedAt
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AttestationSpec.
func (in *AttestationSpec) DeepCopy() *AttestationSpec {
if in == nil {
return nil
}
out := new(AttestationSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PCRValues) DeepCopyInto(out *PCRValues) {
*out = *in
if in.PCRs != nil {
in, out := &in.PCRs, &out.PCRs
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PCRValues.
func (in *PCRValues) DeepCopy() *PCRValues {
if in == nil {
return nil
}
out := new(PCRValues)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PartitionSpec) DeepCopyInto(out *PartitionSpec) {
*out = *in
@@ -164,11 +114,6 @@ func (in *SealedVolumeSpec) DeepCopyInto(out *SealedVolumeSpec) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.Attestation != nil {
in, out := &in.Attestation, &out.Attestation
*out = new(AttestationSpec)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SealedVolumeSpec.

View File

@@ -1,336 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCLI(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Discovery CLI Suite")
}
var _ = Describe("CLI Interface", func() {
BeforeEach(func() {
// Clean up any previous log files
_ = os.Remove("/tmp/kcrypt-challenger-client.log")
})
AfterEach(func() {
// Clean up log files
_ = os.Remove("/tmp/kcrypt-challenger-client.log")
})
Context("CLI help", func() {
It("should show help when --help is used", func() {
err := ExecuteWithArgs([]string{"--help"})
Expect(err).To(BeNil())
// We can't easily test the output content without complex output capture,
// but we can verify the function executes without error
})
})
Context("Input validation", func() {
It("should require all partition parameters for get command", func() {
err := ExecuteWithArgs([]string{"get"})
Expect(err).To(HaveOccurred())
// Should return an error when required parameters are missing
})
It("should validate that all required fields are provided for get command", func() {
// Test with valid partition parameters
err := ExecuteWithArgs([]string{"get", "--partition-name=/dev/sda2"})
Expect(err).To(HaveOccurred()) // Should fail at client connection but parsing should work
// Test with valid UUID
err = ExecuteWithArgs([]string{"get", "--partition-uuid=12345"})
Expect(err).To(HaveOccurred()) // Should fail at client connection but parsing should work
})
It("should handle invalid flags gracefully", func() {
err := ExecuteWithArgs([]string{"--invalid-flag"})
Expect(err).To(HaveOccurred())
// Should return an error for invalid flags
})
})
Context("Configuration overrides with debug logging", func() {
var tempDir string
var originalLogFile string
var testLogFile string
var configDir string
BeforeEach(func() {
// Create a temporary directory for this test
var err error
tempDir, err = os.MkdirTemp("", "kcrypt-test-*")
Expect(err).NotTo(HaveOccurred())
// Use /tmp/oem since it's already in confScanDirs
configDir = "/tmp/oem"
err = os.MkdirAll(configDir, 0755)
Expect(err).NotTo(HaveOccurred())
// Create a test configuration file with known values
configContent := `kcrypt:
challenger:
challenger_server: "https://default-server.com:8080"
mdns: false
certificate: "/default/path/to/cert.pem"
nv_index: "0x1500000"
c_index: "0x1400000"
tpm_device: "/dev/tpm0"
`
configFile := filepath.Join(configDir, "kairos.yaml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
Expect(err).NotTo(HaveOccurred())
// Override the log file location for testing
originalLogFile = os.Getenv("KAIROS_LOG_FILE")
testLogFile = filepath.Join(tempDir, "kcrypt-discovery-challenger.log")
os.Setenv("KAIROS_LOG_FILE", testLogFile)
})
AfterEach(func() {
// Restore original log file setting
if originalLogFile != "" {
os.Setenv("KAIROS_LOG_FILE", originalLogFile)
} else {
os.Unsetenv("KAIROS_LOG_FILE")
}
// Clean up config file
_ = os.RemoveAll(configDir)
// Clean up temporary directory
_ = os.RemoveAll(tempDir)
})
It("should read and use original configuration values without overrides", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--debug",
"--attempts=1",
})
// Should fail at passphrase retrieval but config parsing should work
Expect(err).To(HaveOccurred())
// Check that original configuration values are logged
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
// Should show original configuration values from the file
Expect(logStr).To(ContainSubstring("Original configuration"))
Expect(logStr).To(ContainSubstring("https://default-server.com:8080"))
Expect(logStr).To(ContainSubstring("false")) // mdns value
Expect(logStr).To(ContainSubstring("/default/path/to/cert.pem"))
// Should also show final configuration (which should be the same as original)
Expect(logStr).To(ContainSubstring("Final configuration"))
// Should NOT contain any override messages since no flags were provided
Expect(logStr).NotTo(ContainSubstring("Overriding server URL"))
Expect(logStr).NotTo(ContainSubstring("Overriding MDNS setting"))
Expect(logStr).NotTo(ContainSubstring("Overriding certificate"))
}
})
It("should show configuration file values being overridden by CLI flags", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--challenger-server=https://overridden-server.com:9999",
"--mdns=true",
"--certificate=/overridden/cert.pem",
"--debug",
"--attempts=1",
})
// Should fail at passphrase retrieval but config parsing and overrides should work
Expect(err).To(HaveOccurred())
// Check that both original and overridden values are logged
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
// Should show original configuration values from the file
Expect(logStr).To(ContainSubstring("Original configuration"))
Expect(logStr).To(ContainSubstring("https://default-server.com:8080"))
Expect(logStr).To(ContainSubstring("/default/path/to/cert.pem"))
// Should show override messages
Expect(logStr).To(ContainSubstring("Overriding server URL"))
Expect(logStr).To(ContainSubstring("https://default-server.com:8080 -> https://overridden-server.com:9999"))
Expect(logStr).To(ContainSubstring("Overriding MDNS setting"))
Expect(logStr).To(ContainSubstring("false -> true"))
Expect(logStr).To(ContainSubstring("Overriding certificate"))
// Should show final configuration with overridden values
Expect(logStr).To(ContainSubstring("Final configuration"))
Expect(logStr).To(ContainSubstring("https://overridden-server.com:9999"))
Expect(logStr).To(ContainSubstring("/overridden/cert.pem"))
}
})
It("should apply CLI flag overrides and log configuration changes", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--challenger-server=https://custom-server.com:8082",
"--mdns=true",
"--certificate=/path/to/cert.pem",
"--debug",
"--attempts=1",
})
// Should fail at passphrase retrieval but flag parsing should work
Expect(err).To(HaveOccurred())
// Check if debug log exists and contains configuration information
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
// Should contain debug information about configuration overrides
Expect(logStr).To(ContainSubstring("Overriding server URL"))
Expect(logStr).To(ContainSubstring("https://custom-server.com:8082"))
Expect(logStr).To(ContainSubstring("Overriding MDNS setting"))
Expect(logStr).To(ContainSubstring("Overriding certificate"))
}
})
It("should show original vs final configuration in debug mode", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--challenger-server=https://override-server.com:9999",
"--debug",
"--attempts=1",
})
// Should fail but debug information should be logged
Expect(err).To(HaveOccurred())
// Check for original and final configuration logging
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
Expect(logStr).To(ContainSubstring("Original configuration"))
Expect(logStr).To(ContainSubstring("Final configuration"))
Expect(logStr).To(ContainSubstring("https://override-server.com:9999"))
}
})
It("should log partition details in debug mode", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/custom-partition",
"--partition-uuid=custom-uuid-123",
"--partition-label=custom-label-456",
"--debug",
"--attempts=2",
})
Expect(err).To(HaveOccurred())
// Check for partition details in debug log
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
Expect(logStr).To(ContainSubstring("Partition details"))
Expect(logStr).To(ContainSubstring("/dev/custom-partition"))
Expect(logStr).To(ContainSubstring("custom-uuid-123"))
Expect(logStr).To(ContainSubstring("custom-label-456"))
Expect(logStr).To(ContainSubstring("Attempts: 2"))
}
})
It("should not log debug information without debug flag", func() {
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--attempts=1",
})
Expect(err).To(HaveOccurred())
// Debug log should not exist or should not contain detailed debug info
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
// Should not contain debug-level details
Expect(logStr).NotTo(ContainSubstring("Original configuration"))
Expect(logStr).NotTo(ContainSubstring("Partition details"))
}
})
It("should handle missing configuration file gracefully and show defaults", func() {
// Remove the config file to test default behavior
_ = os.RemoveAll(configDir)
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/test",
"--partition-uuid=test-uuid",
"--partition-label=test-label",
"--debug",
"--attempts=1",
})
// Should fail at passphrase retrieval but not due to config parsing
Expect(err).To(HaveOccurred())
// Check that default/empty configuration values are logged
logContent, readErr := os.ReadFile(testLogFile)
if readErr == nil {
logStr := string(logContent)
// Should show original configuration (which should be empty/defaults)
Expect(logStr).To(ContainSubstring("Original configuration"))
Expect(logStr).To(ContainSubstring("Final configuration"))
// Should NOT contain override messages since no flags were provided
Expect(logStr).NotTo(ContainSubstring("Overriding server URL"))
Expect(logStr).NotTo(ContainSubstring("Overriding MDNS setting"))
Expect(logStr).NotTo(ContainSubstring("Overriding certificate"))
}
})
})
Context("CLI argument parsing", func() {
It("should parse all arguments correctly", func() {
// This will fail at the client creation/server connection,
// but should successfully parse all arguments
err := ExecuteWithArgs([]string{
"get",
"--partition-name=/dev/custom",
"--partition-uuid=custom-uuid-999",
"--partition-label=custom-label",
"--attempts=5",
})
Expect(err).To(HaveOccurred()) // Fails due to no server
// The important thing is that flag parsing worked and it reached the backend
})
It("should handle boolean flags correctly", func() {
// Test help flag
err := ExecuteWithArgs([]string{"--help"})
Expect(err).To(BeNil())
})
})
})

View File

@@ -1,70 +1,57 @@
package client
import (
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"time"
"github.com/google/go-attestation/attest"
"github.com/gorilla/websocket"
"github.com/jaypipes/ghw/pkg/block"
"github.com/kairos-io/kairos-challenger/pkg/attestation"
"github.com/kairos-io/kairos-challenger/pkg/constants"
"github.com/kairos-io/kairos-challenger/pkg/payload"
"github.com/kairos-io/kairos-sdk/kcrypt/bus"
"github.com/kairos-io/kairos-sdk/types"
"github.com/kairos-io/tpm-helpers"
"github.com/mudler/go-pluggable"
"github.com/mudler/yip/pkg/utils"
)
// Retry delays for different failure types
const (
TPMRetryDelay = 100 * time.Millisecond // Brief delay for TPM hardware busy/unavailable
NetworkRetryDelay = 1 * time.Second // Longer delay for network/server issues
)
// Because of how go-pluggable works, we can't just print to stdout
const LOGFILE = "/tmp/kcrypt-challenger-client.log"
var errPartNotFound error = fmt.Errorf("pass for partition not found")
var errBadCertificate error = fmt.Errorf("unknown certificate")
func NewClient() (*Client, error) {
return NewClientWithLogger(types.NewKairosLogger("kcrypt-challenger-client", "error", false))
conf, err := unmarshalConfig()
if err != nil {
return nil, err
}
return &Client{Config: conf}, nil
}
func NewClientWithLogger(logger types.KairosLogger) (*Client, error) {
// No config loading here - config is passed via the DiscoveryPasswordPayload JSON
conf := newEmptyConfig()
return &Client{Config: conf, Logger: logger}, nil
}
// echo '{ "data": "{ \\"label\\": \\"LABEL\\" }"}' | sudo -E WSS_SERVER="http://localhost:8082/challenge" ./challenger "discovery.password"
func (c *Client) Start() error {
if err := os.RemoveAll(LOGFILE); err != nil { // Start fresh
return fmt.Errorf("removing the logfile: %w", err)
}
func (c *Client) Start(eventType pluggable.EventType) error {
factory := pluggable.NewPluginFactory()
// Input: bus.EventInstallPayload
// Expected output: map[string]string{}
factory.Add(bus.EventDiscoveryPassword, func(e *pluggable.Event) pluggable.EventResponse {
payload := &bus.DiscoveryPasswordPayload{}
err := json.Unmarshal([]byte(e.Data), payload)
b := &block.Partition{}
err := json.Unmarshal([]byte(e.Data), b)
if err != nil {
return pluggable.EventResponse{
Error: fmt.Sprintf("failed reading payload: %s", err.Error()),
Error: fmt.Sprintf("failed reading partitions: %s", err.Error()),
}
}
if payload.Partition == nil {
return pluggable.EventResponse{
Error: "partition is required in payload",
}
}
// Apply config from payload
c.Config.Kcrypt.Challenger.Server = payload.ChallengerServer
c.Config.Kcrypt.Challenger.MDNS = payload.MDNS
c.Config.Kcrypt.Challenger.Certificate = payload.Certificate
c.Config.Kcrypt.Challenger.NVIndex = payload.NVIndex
c.Config.Kcrypt.Challenger.CIndex = payload.CIndex
c.Config.Kcrypt.Challenger.TPMDevice = payload.TPMDevice
pass, err := c.GetPassphrase(payload.Partition, 30)
pass, err := c.waitPass(b, 30)
if err != nil {
return pluggable.EventResponse{
Error: fmt.Sprintf("failed getting pass: %s", err.Error()),
@@ -76,12 +63,39 @@ func (c *Client) Start(eventType pluggable.EventType) error {
}
})
return factory.Run(eventType, os.Stdin, os.Stdout)
return factory.Run(pluggable.EventType(os.Args[1]), os.Stdin, os.Stdout)
}
// echo '{ "data": "{ \\"label\\": \\"LABEL\\" }"}' | sudo -E WSS_SERVER="http://localhost:8082/challenge" ./challenger "discovery.password"
// GetPassphrase retrieves a passphrase for the given partition - core business logic
func (c *Client) GetPassphrase(partition *block.Partition, attempts int) (string, error) {
func (c *Client) generatePass(postEndpoint string, headers map[string]string, p *block.Partition) error {
rand := utils.RandomString(32)
pass, err := tpm.EncryptBlob([]byte(rand))
if err != nil {
return err
}
bpass := base64.RawURLEncoding.EncodeToString(pass)
opts := []tpm.Option{
tpm.WithCAs([]byte(c.Config.Kcrypt.Challenger.Certificate)),
tpm.AppendCustomCAToSystemCA,
tpm.WithAdditionalHeader("label", p.FilesystemLabel),
tpm.WithAdditionalHeader("name", p.Name),
tpm.WithAdditionalHeader("uuid", p.UUID),
}
for k, v := range headers {
opts = append(opts, tpm.WithAdditionalHeader(k, v))
}
conn, err := tpm.Connection(postEndpoint, opts...)
if err != nil {
return err
}
return conn.WriteJSON(payload.Data{Passphrase: bpass, GeneratedBy: constants.TPMSecret})
}
func (c *Client) waitPass(p *block.Partition, attempts int) (pass string, err error) {
additionalHeaders := map[string]string{}
serverURL := c.Config.Kcrypt.Challenger.Server
// If we don't have any server configured, just do local
@@ -89,180 +103,44 @@ func (c *Client) GetPassphrase(partition *block.Partition, attempts int) (string
return localPass(c.Config)
}
additionalHeaders := map[string]string{}
var err error
if c.Config.Kcrypt.Challenger.MDNS {
serverURL, additionalHeaders, err = queryMDNS(serverURL, c.Logger)
if err != nil {
return "", err
}
serverURL, additionalHeaders, err = queryMDNS(serverURL)
}
c.Logger.Debugf("Starting TPM attestation flow with server: %s", serverURL)
return c.waitPassWithTPMAttestation(serverURL, additionalHeaders, partition, attempts)
}
// waitPassWithTPMAttestation implements the new TPM remote attestation flow over WebSocket
func (c *Client) waitPassWithTPMAttestation(serverURL string, additionalHeaders map[string]string, p *block.Partition, attempts int) (string, error) {
attestationEndpoint := fmt.Sprintf("%s/tpm-attestation", serverURL)
c.Logger.Debugf("Debug: TPM attestation endpoint: %s", attestationEndpoint)
// Step 1: Initialize Remote Attestation Client (outside the retry loop)
c.Logger.Debugf("Debug: Initializing Remote Attestation Client")
clientOpts := []tpm.Option{}
if c.Config.Kcrypt.Challenger.TPMDevice != "" {
c.Logger.Debugf("Debug: Using TPM device: %s", c.Config.Kcrypt.Challenger.TPMDevice)
clientOpts = append(clientOpts, tpm.WithTPMDevice(c.Config.Kcrypt.Challenger.TPMDevice))
}
attestationClient, err := attestation.NewRemoteAttestationClient(clientOpts...)
if err != nil {
return "", fmt.Errorf("failed to create attestation client: %w", err)
}
c.Logger.Debugf("Debug: Remote Attestation Client initialized successfully")
// Ensure client is properly closed when done
defer func() {
if closeErr := attestationClient.Close(); closeErr != nil {
c.Logger.Debugf("Warning: Failed to close attestation client: %v", closeErr)
}
}()
getEndpoint := fmt.Sprintf("%s/getPass", serverURL)
postEndpoint := fmt.Sprintf("%s/postPass", serverURL)
for tries := 0; tries < attempts; tries++ {
c.Logger.Debugf("Debug: TPM attestation attempt %d/%d", tries+1, attempts)
// Step 2: Start WebSocket-based attestation flow
c.Logger.Debugf("Debug: Starting WebSocket-based attestation flow")
passphrase, err := c.performTPMAttestation(attestationEndpoint, additionalHeaders, attestationClient, p)
if err != nil {
c.Logger.Debugf("Failed TPM attestation: %v", err)
time.Sleep(NetworkRetryDelay)
var generated bool
pass, generated, err = getPass(getEndpoint, additionalHeaders, c.Config.Kcrypt.Challenger.Certificate, p)
if err == errPartNotFound {
// IF server doesn't have a pass for us, then we generate one and we set it
err = c.generatePass(postEndpoint, additionalHeaders, p)
if err != nil {
return
}
// Attempt to fetch again - validate that the server has it now
tries = 0
continue
}
return passphrase, nil
}
return "", fmt.Errorf("exhausted all attempts (%d) for TPM attestation", attempts)
}
// performTPMAttestation handles the complete attestation flow over a single WebSocket connection
func (c *Client) performTPMAttestation(endpoint string, additionalHeaders map[string]string, attestationClient *attestation.RemoteAttestationClient, p *block.Partition) (string, error) {
c.Logger.Debugf("Debug: Creating WebSocket connection to endpoint: %s", endpoint)
c.Logger.Debugf("Debug: Partition details - Label: %s, Name: %s, UUID: %s", p.FilesystemLabel, p.Name, p.UUID)
c.Logger.Debugf("Debug: Certificate length: %d", len(c.Config.Kcrypt.Challenger.Certificate))
// Create WebSocket connection
opts := []tpm.Option{
tpm.WithAdditionalHeader("label", p.FilesystemLabel),
tpm.WithAdditionalHeader("name", p.Name),
tpm.WithAdditionalHeader("uuid", p.UUID),
}
// Only add certificate options if a certificate is provided
if len(c.Config.Kcrypt.Challenger.Certificate) > 0 {
c.Logger.Debugf("Debug: Adding certificate validation options")
opts = append(opts,
tpm.WithCAs([]byte(c.Config.Kcrypt.Challenger.Certificate)),
tpm.AppendCustomCAToSystemCA,
)
} else {
c.Logger.Debugf("Debug: No certificate provided, using insecure connection")
}
for k, v := range additionalHeaders {
opts = append(opts, tpm.WithAdditionalHeader(k, v))
}
c.Logger.Debugf("Debug: WebSocket options configured, attempting connection...")
// Add connection timeout to prevent hanging indefinitely
type connectionResult struct {
conn interface{}
err error
}
done := make(chan connectionResult, 1)
go func() {
c.Logger.Debugf("Debug: Using tpm.AttestationConnection for new TPM flow")
conn, err := tpm.AttestationConnection(endpoint, opts...)
c.Logger.Debugf("Debug: tpm.AttestationConnection returned with err: %v", err)
done <- connectionResult{conn: conn, err: err}
}()
var conn *websocket.Conn
select {
case result := <-done:
if result.err != nil {
c.Logger.Debugf("Debug: WebSocket connection failed: %v", result.err)
return "", fmt.Errorf("creating WebSocket connection: %w", result.err)
if generated { // passphrase is encrypted
return c.decryptPassphrase(pass)
}
var ok bool
conn, ok = result.conn.(*websocket.Conn)
if !ok {
return "", fmt.Errorf("unexpected connection type")
if err == errBadCertificate { // No need to retry, won't succeed.
return
}
c.Logger.Debugf("Debug: WebSocket connection established successfully")
case <-time.After(10 * time.Second):
c.Logger.Debugf("Debug: WebSocket connection timed out after 10 seconds")
return "", fmt.Errorf("WebSocket connection timed out")
if err == nil { // passphrase available, no errors
return
}
logToFile("Failed with error: %s . Will retry.\n", err.Error())
time.Sleep(1 * time.Second) // network errors? retry
}
defer conn.Close() //nolint:errcheck
// Protocol Step 1: Create attestation init
c.Logger.Debugf("Debug: Creating attestation init")
initBytes, err := attestationClient.CreateInit()
if err != nil {
return "", fmt.Errorf("creating attestation init: %w", err)
}
c.Logger.Debugf("Debug: Attestation init created successfully")
// Send attestation init to server
c.Logger.Debugf("Debug: Sending attestation init to server")
if err := conn.WriteMessage(websocket.BinaryMessage, initBytes); err != nil {
return "", fmt.Errorf("sending attestation init: %w", err)
}
c.Logger.Debugf("Debug: Attestation init sent successfully")
// Protocol Step 2: Wait for challenge response from server
c.Logger.Debugf("Debug: Waiting for challenge from server")
_, challengeBytes, err := conn.ReadMessage()
if err != nil {
return "", fmt.Errorf("reading challenge from server: %w", err)
}
c.Logger.Debugf("Challenge received")
// Protocol Step 3: Handle challenge
c.Logger.Debugf("Debug: Handling challenge")
// Use default PCRs for now - this could be made configurable
pcrs := []int{0, 7, 11} // Common PCRs used in the system
proofBytes, err := attestationClient.HandleChallenge(challengeBytes, pcrs)
if err != nil {
c.Logger.Debugf("Debug: HandleChallenge failed: %v", err)
return "", fmt.Errorf("handling challenge: %w", err)
}
c.Logger.Debugf("Debug: Challenge handled successfully")
// Protocol Step 4: Send proof to server
c.Logger.Debugf("Debug: Sending proof to server")
if err := conn.WriteMessage(websocket.BinaryMessage, proofBytes); err != nil {
return "", fmt.Errorf("sending proof: %w", err)
}
c.Logger.Debugf("Proof sent")
// Protocol Step 5: Receive passphrase from server
c.Logger.Debugf("Debug: Waiting for passphrase response")
_, passphraseBytes, err := conn.ReadMessage()
if err != nil {
return "", fmt.Errorf("reading passphrase response: %w", err)
}
c.Logger.Debugf("Passphrase received - Length: %d bytes", len(passphraseBytes))
// Check if we received an empty passphrase (indicates server error)
if len(passphraseBytes) == 0 {
return "", fmt.Errorf("server returned empty passphrase, indicating an error occurred during attestation")
}
return string(passphraseBytes), nil
return
}
// decryptPassphrase decodes (base64) and decrypts the passphrase returned
@@ -286,25 +164,13 @@ func (c *Client) decryptPassphrase(pass string) (string, error) {
return string(passBytes), err
}
// encodeEKToBytes encodes an EK to PEM bytes for transmission
func encodeEKToBytes(ek *attest.EK) ([]byte, error) {
if ek.Certificate != nil {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: ek.Certificate.Raw,
}
return pem.EncodeToMemory(pemBlock), nil
}
// For EKs without certificates, marshal the public key
pubBytes, err := x509.MarshalPKIXPublicKey(ek.Public)
func logToFile(format string, a ...any) {
s := fmt.Sprintf(format, a...)
file, err := os.OpenFile(LOGFILE, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("marshaling EK public key: %w", err)
panic(err)
}
defer file.Close()
pemBlock := &pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
}
return pem.EncodeToMemory(pemBlock), nil
file.WriteString(s)
}

View File

@@ -1,12 +1,19 @@
package client
import (
"github.com/kairos-io/kairos-sdk/types"
"github.com/kairos-io/kairos-sdk/collector"
"gopkg.in/yaml.v3"
)
// There are the directories under which we expect to find kairos configuration.
// When we are booted from an iso (during installation), configuration is expected
// under `/oem`. When we are booting an installed system (in initramfs phase),
// the path is `/sysroot/oem`.
// When we run the challenger in hooks, we may have the config under /tmp/oem
var confScanDirs = []string{"/oem", "/sysroot/oem", "/tmp/oem"}
type Client struct {
Config Config
Logger types.KairosLogger
}
type Config struct {
@@ -22,8 +29,26 @@ type Config struct {
}
}
// newEmptyConfig returns an empty config
// The actual config is now passed via the DiscoveryPasswordPayload JSON from the caller
func newEmptyConfig() Config {
return Config{}
func unmarshalConfig() (Config, error) {
var result Config
o := &collector.Options{NoLogs: true, MergeBootCMDLine: false}
if err := o.Apply(collector.Directories(confScanDirs...)); err != nil {
return result, err
}
c, err := collector.Scan(o, func(d []byte) ([]byte, error) {
return d, nil
})
if err != nil {
return result, err
}
a, _ := c.String()
err = yaml.Unmarshal([]byte(a), &result)
if err != nil {
return result, err
}
return result, nil
}

View File

@@ -1,11 +1,58 @@
package client
import (
"encoding/json"
"fmt"
"strings"
"github.com/kairos-io/kairos-challenger/pkg/constants"
"github.com/kairos-io/kairos-challenger/pkg/payload"
"github.com/jaypipes/ghw/pkg/block"
"github.com/kairos-io/tpm-helpers"
"github.com/mudler/yip/pkg/utils"
"github.com/pkg/errors"
)
const DefaultNVIndex = "0x1500000"
func getPass(server string, headers map[string]string, certificate string, partition *block.Partition) (string, bool, error) {
opts := []tpm.Option{
tpm.WithCAs([]byte(certificate)),
tpm.AppendCustomCAToSystemCA,
tpm.WithAdditionalHeader("label", partition.FilesystemLabel),
tpm.WithAdditionalHeader("name", partition.Name),
tpm.WithAdditionalHeader("uuid", partition.UUID),
}
for k, v := range headers {
opts = append(opts, tpm.WithAdditionalHeader(k, v))
}
msg, err := tpm.Get(server, opts...)
if err != nil {
return "", false, err
}
result := payload.Data{}
err = json.Unmarshal(msg, &result)
if err != nil {
return "", false, errors.Wrap(err, string(msg))
}
if result.HasPassphrase() {
return fmt.Sprint(result.Passphrase), result.HasBeenGenerated() && result.GeneratedBy == constants.TPMSecret, nil
} else if result.HasError() {
if strings.Contains(result.Error, "No secret found for") {
return "", false, errPartNotFound
}
if strings.Contains(result.Error, "x509: certificate signed by unknown authority") {
return "", false, errBadCertificate
}
return "", false, errors.New(result.Error)
}
return "", false, errPartNotFound
}
func genAndStore(k Config) (string, error) {
opts := []tpm.TPMOption{}
if k.Kcrypt.Challenger.TPMDevice != "" {
@@ -21,7 +68,7 @@ func genAndStore(k Config) (string, error) {
if err != nil {
return "", err
}
nvindex := constants.LocalPassphraseNVIndex
nvindex := DefaultNVIndex
if k.Kcrypt.Challenger.NVIndex != "" {
nvindex = k.Kcrypt.Challenger.NVIndex
}
@@ -30,7 +77,7 @@ func genAndStore(k Config) (string, error) {
}
func localPass(k Config) (string, error) {
index := constants.LocalPassphraseNVIndex
index := DefaultNVIndex
if k.Kcrypt.Challenger.NVIndex != "" {
index = k.Kcrypt.Challenger.NVIndex
}

View File

@@ -1,47 +0,0 @@
package client
import (
"testing"
"github.com/kairos-io/kairos-sdk/types"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestClient(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Discovery Client Suite")
}
var _ = Describe("Flow Detection", func() {
var client *Client
BeforeEach(func() {
// Create a test client with basic config and logger
client = &Client{}
client.Config.Kcrypt.Challenger.Server = "http://test-server.local"
client.Logger = types.NewKairosLogger("test-client", "debug", false)
})
Context("TPM attestation capabilities", func() {
It("should handle TPM operations", func() {
// Test that client can be created without errors
// TPM availability testing requires actual hardware
Expect(client).ToNot(BeNil())
})
})
Context("Logging functionality", func() {
It("should have a valid logger", func() {
// Test that client has a valid logger
Expect(client.Logger).NotTo(BeNil())
// Test debug logging works without error
client.Logger.Debugf("Test log entry for flow detection")
// If we get here without panic, logging is working
Expect(true).To(BeTrue())
})
})
})

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/hashicorp/mdns"
"github.com/kairos-io/kairos-sdk/types"
)
const (
@@ -19,7 +18,7 @@ const (
// queryMDNS will make an mdns query on local network to find a kcrypt challenger server
// instance. If none is found, the original URL is returned and no additional headers.
// If a response is received, the IP address and port from the response will be returned// and an additional "Host" header pointing to the original host.
func queryMDNS(originalURL string, logger types.KairosLogger) (string, map[string]string, error) {
func queryMDNS(originalURL string) (string, map[string]string, error) {
additionalHeaders := map[string]string{}
var err error
@@ -33,9 +32,9 @@ func queryMDNS(originalURL string, logger types.KairosLogger) (string, map[strin
return "", additionalHeaders, fmt.Errorf("domain should end in \".local\" when using mdns")
}
mdnsIP, mdnsPort := discoverMDNSServer(host, logger)
mdnsIP, mdnsPort := discoverMDNSServer(host)
if mdnsIP == "" { // no reply
logger.Debugf("no reply from mdns")
logToFile("no reply from mdns\n")
return originalURL, additionalHeaders, nil
}
@@ -57,12 +56,12 @@ func queryMDNS(originalURL string, logger types.KairosLogger) (string, map[strin
// discoverMDNSServer performs an mDNS query to discover any running kcrypt challenger
// servers on the same network that matches the given hostname.
// If a response if received, the IP address and the Port from the response are returned.
func discoverMDNSServer(hostname string, logger types.KairosLogger) (string, string) {
func discoverMDNSServer(hostname string) (string, string) {
// Make a channel for results and start listening
entriesCh := make(chan *mdns.ServiceEntry, 4)
defer close(entriesCh)
logger.Debugf("Will now wait for some mdns server to respond")
logToFile("Will now wait for some mdns server to respond\n")
// Start the lookup. It will block until we read from the chan.
mdns.Lookup(MDNSServiceType, entriesCh)
@@ -71,15 +70,15 @@ func discoverMDNSServer(hostname string, logger types.KairosLogger) (string, str
for {
select {
case entry := <-entriesCh:
logger.Debugf("mdns response received")
logToFile("mdns response received\n")
if entry.Host == expectedHost {
logger.Debugf("%s matches %s", entry.Host, expectedHost)
logToFile("%s matches %s\n", entry.Host, expectedHost)
return entry.AddrV4.String(), strconv.Itoa(entry.Port) // TODO: v6?
} else {
logger.Debugf("%s didn't match %s", entry.Host, expectedHost)
logToFile("%s didn't match %s\n", entry.Host, expectedHost)
}
case <-time.After(MDNSTimeout):
logger.Debugf("timed out waiting for mdns")
logToFile("timed out waiting for mdns\n")
return "", ""
}
}

View File

@@ -1,486 +1,53 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/jaypipes/ghw/pkg/block"
"github.com/kairos-io/kairos-challenger/cmd/discovery/client"
attpkg "github.com/kairos-io/kairos-challenger/pkg/attestation"
"github.com/kairos-io/kairos-sdk/kcrypt/bus"
"github.com/kairos-io/kairos-sdk/types"
"github.com/kairos-io/tpm-helpers"
"github.com/mudler/go-pluggable"
"github.com/spf13/cobra"
)
// GetFlags holds all flags specific to the get command
type GetFlags struct {
PartitionName string
PartitionUUID string
PartitionLabel string
Attempts int
ChallengerServer string
EnableMDNS bool
ServerCertificate string
}
var (
// Global/persistent flags
debug bool
)
// rootCmd represents the base command (TPM hash generation)
var rootCmd = &cobra.Command{
Use: "kcrypt-discovery-challenger",
Short: "kcrypt-challenger discovery client",
Long: `kcrypt-challenger discovery client
This tool provides TPM-based operations for encrypted partition management.
By default, it outputs the TPM hash for this device.
Configuration:
The client reads configuration from Kairos configuration files in the following directories:
- /oem (during installation from ISO)
- /sysroot/oem (on installed systems during initramfs)
- /tmp/oem (when running in hooks)
Configuration format (YAML):
kcrypt:
challenger:
challenger_server: "https://my-server.com:8082" # Server URL
mdns: true # Enable mDNS discovery
certificate: "/path/to/server-cert.pem" # Server certificate
nv_index: "0x1500000" # TPM NV index (offline mode)
c_index: "0x1500001" # TPM certificate index
tpm_device: "/dev/tpmrm0" # TPM device path`,
Example: ` # Get TPM hash for this device (default)
kcrypt-discovery-challenger
# Get passphrase for encrypted partition
kcrypt-discovery-challenger get --partition-name=/dev/sda2
# Clean up TPM NV memory (useful for development)
kcrypt-discovery-challenger cleanup
# Run plugin event
kcrypt-discovery-challenger discovery.password`,
RunE: func(cmd *cobra.Command, args []string) error {
return runTPMHash()
},
}
// newCleanupCmd creates the cleanup command
func newCleanupCmd() *cobra.Command {
var nvIndex string
var tpmDevice string
var skipConfirmation bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up TPM NV memory",
Long: `Clean up TPM NV memory by undefining specific NV indices.
⚠️ DANGER: This command removes encryption passphrases from TPM memory!
⚠️ If you delete the wrong index, your encrypted disk may become UNBOOTABLE!
This command helps clean up TPM NV memory used by the local pass flow,
which stores encrypted passphrases in TPM non-volatile memory. Without
cleanup, these passphrases persist indefinitely and take up space.
The command will prompt for confirmation before deletion unless you use
the --i-know-what-i-am-doing flag to skip the safety prompt.
Default behavior:
- Uses the same NV index as the local pass flow (from config or 0x1500000)
- Uses the same TPM device as configured (or system default if none specified)
- Prompts for confirmation with safety warnings`,
Example: ` # Clean up default NV index (with confirmation prompt)
kcrypt-discovery-challenger cleanup
# Clean up specific NV index
kcrypt-discovery-challenger cleanup --nv-index=0x1500001
# Clean up with specific TPM device
kcrypt-discovery-challenger cleanup --tpm-device=/dev/tpmrm0
# Skip confirmation prompt (DANGEROUS!)
kcrypt-discovery-challenger cleanup --i-know-what-i-am-doing`,
RunE: func(cmd *cobra.Command, args []string) error {
return runCleanup(nvIndex, tpmDevice, skipConfirmation)
},
}
cmd.Flags().StringVar(&nvIndex, "nv-index", "", "NV index to clean up (defaults to configured index or 0x1500000)")
cmd.Flags().StringVar(&tpmDevice, "tpm-device", "", "TPM device path (defaults to configured device or system default)")
cmd.Flags().BoolVar(&skipConfirmation, "i-know-what-i-am-doing", false, "Skip confirmation prompt (DANGEROUS: may make encrypted disks unbootable)")
return cmd
}
// newGetCmd creates the get command with its flags
func newGetCmd() *cobra.Command {
flags := &GetFlags{}
cmd := &cobra.Command{
Use: "get",
Short: "Get passphrase for encrypted partition",
Long: `Get passphrase for encrypted partition using TPM attestation.
This command retrieves passphrases for encrypted partitions by communicating
with a challenger server using TPM-based attestation. At least one partition
identifier (name, UUID, or label) must be provided.
The command uses configuration from the root command's config files, but flags
can override specific settings:
--challenger-server Override kcrypt.challenger.challenger_server
--mdns Override kcrypt.challenger.mdns
--certificate Override kcrypt.challenger.certificate`,
Example: ` # Get passphrase using partition name
kcrypt-discovery-challenger get --partition-name=/dev/sda2
# Get passphrase using UUID
kcrypt-discovery-challenger get --partition-uuid=12345-abcde
# Get passphrase using filesystem label
kcrypt-discovery-challenger get --partition-label=encrypted-data
# Get passphrase with multiple identifiers
kcrypt-discovery-challenger get --partition-name=/dev/sda2 --partition-uuid=12345-abcde --partition-label=encrypted-data
# Get passphrase with custom server
kcrypt-discovery-challenger get --partition-label=encrypted-data --challenger-server=https://my-server.com:8082`,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Validate that at least one partition identifier is provided
if flags.PartitionName == "" && flags.PartitionUUID == "" && flags.PartitionLabel == "" {
return fmt.Errorf("at least one of --partition-name, --partition-uuid, or --partition-label must be provided")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runGetPassphrase(flags)
},
}
// Register flags
cmd.Flags().StringVar(&flags.PartitionName, "partition-name", "", "Name of the partition (at least one identifier required)")
cmd.Flags().StringVar(&flags.PartitionUUID, "partition-uuid", "", "UUID of the partition (at least one identifier required)")
cmd.Flags().StringVar(&flags.PartitionLabel, "partition-label", "", "Filesystem label of the partition (at least one identifier required)")
cmd.Flags().IntVar(&flags.Attempts, "attempts", 30, "Number of attempts to get the passphrase")
cmd.Flags().StringVar(&flags.ChallengerServer, "challenger-server", "", "URL of the challenger server (overrides config)")
cmd.Flags().BoolVar(&flags.EnableMDNS, "mdns", false, "Enable mDNS discovery (overrides config)")
cmd.Flags().StringVar(&flags.ServerCertificate, "certificate", "", "Server certificate for verification (overrides config)")
return cmd
}
// pluginCmd represents the plugin event commands
var pluginCmd = &cobra.Command{
Use: string(bus.EventDiscoveryPassword),
Short: fmt.Sprintf("Run %s plugin event", bus.EventDiscoveryPassword),
Long: fmt.Sprintf(`Run the %s plugin event.
This command runs in plugin mode, reading JSON partition data from stdin
and outputting the passphrase to stdout. This is used for integration
with kcrypt and other tools.`, bus.EventDiscoveryPassword),
Example: fmt.Sprintf(` # Plugin mode (for integration with kcrypt)
echo '{"data": "{\"name\": \"/dev/sda2\", \"uuid\": \"12345-abcde\", \"label\": \"encrypted-data\"}"}' | kcrypt-discovery-challenger %s`, bus.EventDiscoveryPassword),
RunE: func(cmd *cobra.Command, args []string) error {
return runPluginMode(bus.EventDiscoveryPassword)
},
}
func init() {
// Global/persistent flags (available to all commands)
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug logging")
// Add subcommands
rootCmd.AddCommand(newGetCmd())
rootCmd.AddCommand(newCleanupCmd())
rootCmd.AddCommand(pluginCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
if len(os.Args) >= 2 && isEventDefined(os.Args[1]) {
c, err := client.NewClient()
checkErr(err)
checkErr(c.Start())
return
}
pubhash, err := tpm.GetPubHash()
checkErr(err)
fmt.Print(pubhash)
}
func checkErr(err error) {
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// ExecuteWithArgs executes the root command with the given arguments.
// This function is used by tests to simulate CLI execution.
func ExecuteWithArgs(args []string) error {
// Set command arguments (this overrides os.Args)
rootCmd.SetArgs(args)
return rootCmd.Execute()
}
// runTPMHash handles the root command - TPM hash generation
func runTPMHash() error {
// Create logger based on debug flag
var logger types.KairosLogger
if debug {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "debug", false)
logger.Debugf("Debug mode enabled for TPM hash generation")
} else {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "error", false)
}
// Load configuration to get TPM device
config, err := client.NewClientWithLogger(logger)
if err != nil {
logger.Debugf("Warning: Could not load configuration: %v", err)
// Continue with defaults - not a fatal error
}
// Initialize AK Manager for transient AK approach
logger.Debugf("Initializing AK Manager for transient AK approach")
akManagerOpts := []tpm.Option{}
if config != nil && config.Config.Kcrypt.Challenger.TPMDevice != "" {
logger.Debugf("Using TPM device: %s", config.Config.Kcrypt.Challenger.TPMDevice)
akManagerOpts = append(akManagerOpts, tpm.WithTPMDevice(config.Config.Kcrypt.Challenger.TPMDevice))
}
akManager, err := tpm.NewAKManager(akManagerOpts...)
if err != nil {
return fmt.Errorf("creating AK manager: %w", err)
}
logger.Debugf("AK Manager initialized successfully")
// Get EK for attestation (transient AK approach)
logger.Debugf("Getting EK for attestation")
ek, err := akManager.GetEK()
if err != nil {
return fmt.Errorf("getting EK: %w", err)
}
logger.Debugf("Attestation data retrieved successfully")
// Compute TPM hash from EK using attestation helper (SHA-256 of EK SPKI)
logger.Debugf("Computing TPM hash from EK")
tpmHash, err := attpkg.ComputeTPMHashFromEK(ek)
if err != nil {
return fmt.Errorf("computing TPM hash: %w", err)
}
logger.Debugf("TPM hash computed successfully: %s", tpmHash)
// Output the TPM hash to stdout
fmt.Print(tpmHash)
return nil
}
// runGetPassphrase handles the get subcommand - passphrase retrieval
func runGetPassphrase(flags *GetFlags) error {
// Create logger based on debug flag
var logger types.KairosLogger
if debug {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "debug", false)
} else {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "error", false)
}
// Create client with potential CLI overrides
c, err := createClientWithOverrides(flags.ChallengerServer, flags.EnableMDNS, flags.ServerCertificate, logger)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
// Create partition object
partition := &block.Partition{
Name: flags.PartitionName,
UUID: flags.PartitionUUID,
FilesystemLabel: flags.PartitionLabel,
}
// Log partition information
logger.Debugf("Partition details:")
logger.Debugf(" Name: %s", partition.Name)
logger.Debugf(" UUID: %s", partition.UUID)
logger.Debugf(" Label: %s", partition.FilesystemLabel)
logger.Debugf(" Attempts: %d", flags.Attempts)
// Get the passphrase using the same backend logic as the plugin
fmt.Fprintf(os.Stderr, "Requesting passphrase for partition %s (UUID: %s, Label: %s)...\n",
flags.PartitionName, flags.PartitionUUID, flags.PartitionLabel)
passphrase, err := c.GetPassphrase(partition, flags.Attempts)
if err != nil {
return fmt.Errorf("getting passphrase: %w", err)
}
// Output the passphrase to stdout (this is what tools expect)
fmt.Print(passphrase)
fmt.Fprintf(os.Stderr, "\nPassphrase retrieved successfully\n")
return nil
}
// runPluginMode handles plugin event commands
func runPluginMode(eventType pluggable.EventType) error {
// In plugin mode, use quiet=true to log to file instead of console
// Log level depends on debug flag, write logs to /var/log/kairos/kcrypt-discovery-challenger.log
var logLevel string
if debug {
logLevel = "debug"
} else {
logLevel = "error"
}
logLevel = "debug" // Temporarily set this to debug always
logger := types.NewKairosLoggerWithExtraDirs("kcrypt-discovery-challenger", logLevel, true, "/var/log/kairos")
logger.Debugf("Debug mode enabled for plugin mode")
c, err := client.NewClientWithLogger(logger)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
err = c.Start(eventType)
if err != nil {
return fmt.Errorf("starting plugin: %w", err)
}
return nil
}
// createClientWithOverrides creates a client and applies CLI flag overrides to the config
func createClientWithOverrides(serverURL string, enableMDNS bool, certificate string, logger types.KairosLogger) (*client.Client, error) {
// Start with the default config from files and pass the logger
c, err := client.NewClientWithLogger(logger)
if err != nil {
return nil, err
}
// Log the original configuration values
logger.Debugf("Original configuration:")
logger.Debugf(" Server: %s", c.Config.Kcrypt.Challenger.Server)
logger.Debugf(" MDNS: %t", c.Config.Kcrypt.Challenger.MDNS)
logger.Debugf(" Certificate: %s", maskSensitiveString(c.Config.Kcrypt.Challenger.Certificate))
// Apply CLI overrides if provided
if serverURL != "" {
logger.Debugf("Overriding server URL: %s -> %s", c.Config.Kcrypt.Challenger.Server, serverURL)
c.Config.Kcrypt.Challenger.Server = serverURL
}
// For boolean flags, we can directly use the value since Cobra handles it properly
if enableMDNS {
logger.Debugf("Overriding MDNS setting: %t -> %t", c.Config.Kcrypt.Challenger.MDNS, enableMDNS)
c.Config.Kcrypt.Challenger.MDNS = enableMDNS
}
if certificate != "" {
logger.Debugf("Overriding certificate: %s -> %s",
maskSensitiveString(c.Config.Kcrypt.Challenger.Certificate),
maskSensitiveString(certificate))
c.Config.Kcrypt.Challenger.Certificate = certificate
}
// Log the final configuration values
logger.Debugf("Final configuration:")
logger.Debugf(" Server: %s", c.Config.Kcrypt.Challenger.Server)
logger.Debugf(" MDNS: %t", c.Config.Kcrypt.Challenger.MDNS)
logger.Debugf(" Certificate: %s", maskSensitiveString(c.Config.Kcrypt.Challenger.Certificate))
return c, nil
}
// runCleanup handles the cleanup subcommand - TPM NV memory cleanup
func runCleanup(nvIndex, tpmDevice string, skipConfirmation bool) error {
// Create logger based on debug flag
var logger types.KairosLogger
if debug {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "debug", false)
logger.Debugf("Debug mode enabled for TPM NV cleanup")
} else {
logger = types.NewKairosLogger("kcrypt-discovery-challenger", "error", false)
}
// Load configuration to get defaults if flags not provided
var config client.Config
c, err := client.NewClientWithLogger(logger)
if err != nil {
logger.Debugf("Warning: Could not load configuration: %v", err)
// Continue with defaults - not a fatal error
} else {
config = c.Config
}
// Determine NV index to clean up (follow same pattern as localPass/genAndStore)
targetIndex := nvIndex
if targetIndex == "" {
// First check config, then fall back to the same default used by the local pass flow
if config.Kcrypt.Challenger.NVIndex != "" {
targetIndex = config.Kcrypt.Challenger.NVIndex
} else {
targetIndex = "0x1500000" // Default local passphrase NV index
// isEventDefined checks whether an event is defined in the bus.
// It accepts strings or EventType, returns a boolean indicating that
// the event was defined among the events emitted by the bus.
func isEventDefined(i interface{}) bool {
checkEvent := func(e pluggable.EventType) bool {
if e == bus.EventDiscoveryPassword {
return true
}
return false
}
// Determine TPM device
targetDevice := tpmDevice
if targetDevice == "" && config.Kcrypt.Challenger.TPMDevice != "" {
targetDevice = config.Kcrypt.Challenger.TPMDevice
switch f := i.(type) {
case string:
return checkEvent(pluggable.EventType(f))
case pluggable.EventType:
return checkEvent(f)
default:
return false
}
logger.Debugf("Cleaning up TPM NV index: %s", targetIndex)
if targetDevice != "" {
logger.Debugf("Using TPM device: %s", targetDevice)
}
// Check if the NV index exists first
opts := []tpm.TPMOption{tpm.WithIndex(targetIndex)}
if targetDevice != "" {
opts = append(opts, tpm.WithDevice(targetDevice))
}
// Try to read from the index to see if it exists
logger.Debugf("Checking if NV index %s exists", targetIndex)
_, err = tpm.ReadBlob(opts...)
if err != nil {
// If we can't read it, it might not exist or be empty
logger.Debugf("NV index %s appears to be empty or non-existent: %v", targetIndex, err)
fmt.Printf("NV index %s appears to be empty or does not exist\n", targetIndex)
return nil
}
// Confirmation prompt with warning
if !skipConfirmation {
fmt.Printf("\n⚠ WARNING: You are about to delete TPM NV index %s\n", targetIndex)
fmt.Printf("⚠️ If this index contains your disk encryption passphrase, your encrypted disk will become UNBOOTABLE!\n")
fmt.Printf("⚠️ This action CANNOT be undone.\n\n")
fmt.Printf("Are you sure you want to continue? (type 'yes' to confirm): ")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
response := strings.TrimSpace(strings.ToLower(scanner.Text()))
if response != "yes" {
fmt.Printf("Cleanup cancelled.\n")
return nil
}
}
// Use native Go TPM library to undefine the NV space
logger.Debugf("Using native TPM library to undefine NV index")
fmt.Printf("Cleaning up TPM NV index %s...\n", targetIndex)
err = tpm.UndefineBlob(opts...)
if err != nil {
return fmt.Errorf("failed to undefine NV index %s: %w", targetIndex, err)
}
fmt.Printf("Successfully cleaned up NV index %s\n", targetIndex)
logger.Debugf("Successfully undefined NV index %s", targetIndex)
return nil
}
// maskSensitiveString masks certificate paths/content for logging
func maskSensitiveString(s string) string {
if s == "" {
return "<empty>"
}
if len(s) <= 10 {
return strings.Repeat("*", len(s))
}
// Show first 3 and last 3 characters with * in between
return s[:3] + strings.Repeat("*", len(s)-6) + s[len(s)-3:]
}

View File

@@ -37,37 +37,6 @@ spec:
properties:
TPMHash:
type: string
attestation:
description: AttestationSpec defines TPM attestation data for TOFU
enrollment and verification With transient AK approach, only the
EK is stored as the trusted identity
properties:
ekPublicKey:
description: EKPublicKey stores the Endorsement Key public key
in PEM format This is the single trusted identity for the TPM
type: string
enrolledAt:
description: EnrolledAt timestamp when this TPM was first enrolled
format: date-time
type: string
lastVerifiedAt:
description: LastVerifiedAt timestamp of the last successful attestation
format: date-time
type: string
pcrValues:
description: PCRValues stores the expected PCR values for boot
state verification
properties:
pcrs:
additionalProperties:
type: string
description: 'PCRs is a flexible map of PCR index (as string)
to PCR value (hex-encoded) Example: {"0": "a1b2c3...", "7":
"d4e5f6...", "11": "g7h8i9..."} This allows for any combination
of PCRs without hardcoding specific indices'
type: object
type: object
type: object
partitions:
items:
description: 'PartitionSpec defines a Partition. A partition can

View File

@@ -25,6 +25,11 @@ bases:
#- ../prometheus
patchesStrategicMerge:
# Protect the /metrics endpoint by putting it behind auth.
# If you want your controller-manager to expose the /metrics
# endpoint w/o any authn/z, please comment the following line.
- manager_auth_proxy_patch.yaml
# Mount the controller config file for loading manager configurations
# through a ComponentConfig type
#- manager_config_patch.yaml

View File

@@ -0,0 +1,39 @@
# This patch inject a sidecar container which is a HTTP proxy for the
# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews.
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
spec:
template:
spec:
containers:
- name: kube-rbac-proxy
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.0
args:
- "--secure-listen-address=0.0.0.0:8443"
- "--upstream=http://127.0.0.1:8080/"
- "--logtostderr=true"
- "--v=0"
ports:
- containerPort: 8443
protocol: TCP
name: https
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 5m
memory: 64Mi
- name: manager
args:
- "--health-probe-bind-address=:8081"
- "--metrics-bind-address=127.0.0.1:8080"
- "--leader-elect"

View File

@@ -25,6 +25,10 @@ bases:
#- ../prometheus
patchesStrategicMerge:
# Protect the /metrics endpoint by putting it behind auth.
# If you want your controller-manager to expose the /metrics
# endpoint w/o any authn/z, please comment the following line.
- manager_auth_proxy_patch.yaml
- pull.yaml
# Mount the controller config file for loading manager configurations
# through a ComponentConfig type

View File

@@ -0,0 +1,39 @@
# This patch inject a sidecar container which is a HTTP proxy for the
# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews.
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
spec:
template:
spec:
containers:
- name: kube-rbac-proxy
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.0
args:
- "--secure-listen-address=0.0.0.0:8443"
- "--upstream=http://127.0.0.1:8080/"
- "--logtostderr=true"
- "--v=0"
ports:
- containerPort: 8443
protocol: TCP
name: https
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 5m
memory: 64Mi
- name: manager
args:
- "--health-probe-bind-address=:8081"
- "--metrics-bind-address=127.0.0.1:8080"
- "--leader-elect"

View File

@@ -9,6 +9,4 @@ spec:
containers:
- name: manager
imagePullPolicy: IfNotPresent
- name: kube-rbac-proxy
imagePullPolicy: IfNotPresent

View File

@@ -34,41 +34,10 @@ spec:
# seccompProfile:
# type: RuntimeDefault
containers:
- name: kube-rbac-proxy
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.0
args:
- "--secure-listen-address=0.0.0.0:8443"
- "--upstream=http://127.0.0.1:8080/"
- "--logtostderr=true"
- "--v=0"
ports:
- containerPort: 8443
protocol: TCP
name: https
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 5m
memory: 64Mi
- command:
- /manager
args:
- "--health-probe-bind-address=:8081"
- "--metrics-bind-address=127.0.0.1:8080"
- "--leader-elect"
- "--namespace=$(POD_NAMESPACE)"
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- --leader-elect
image: controller:latest
name: manager
securityContext:

View File

@@ -69,7 +69,8 @@ var _ = BeforeSuite(func() {
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
})
}, 60)
var _ = AfterSuite(func() {
By("tearing down the test environment")

View File

@@ -1,64 +0,0 @@
#!/bin/bash
# Example script demonstrating the new CLI interface for kcrypt-challenger
# This makes testing and debugging much easier than using the plugin interface
echo "=== kcrypt-challenger CLI Examples ==="
echo
# Build the binary if it doesn't exist
if [ ! -f "./kcrypt-discovery-challenger" ]; then
echo "Building kcrypt-discovery-challenger..."
go build -o kcrypt-discovery-challenger ./cmd/discovery/
echo
fi
echo "1. Show help:"
./kcrypt-discovery-challenger --help
echo
echo "2. Show version:"
./kcrypt-discovery-challenger --version
echo
echo "3. Test CLI mode with example parameters (will fail without server, but shows the flow):"
echo " Command: ./kcrypt-discovery-challenger --partition-name=/dev/sda2 --partition-uuid=12345-abcde --partition-label=encrypted-data --attempts=1"
echo " Expected: Error connecting to server, but flow detection should work"
echo
./kcrypt-discovery-challenger --partition-name=/dev/sda2 --partition-uuid=12345-abcde --partition-label=encrypted-data --attempts=1 2>&1 || true
echo
echo "4. Test CLI mode with configuration overrides:"
echo " Command: ./kcrypt-discovery-challenger --partition-name=/dev/sda2 --partition-uuid=12345-abcde --partition-label=encrypted-data --challenger-server=https://custom-server.com:8082 --mdns=true --attempts=1"
echo " Expected: Same error but with custom server configuration"
echo
./kcrypt-discovery-challenger --partition-name=/dev/sda2 --partition-uuid=12345-abcde --partition-label=encrypted-data --challenger-server=https://custom-server.com:8082 --mdns=true --attempts=1 2>&1 || true
echo
echo "4. Check the log file for flow detection:"
if [ -f "/tmp/kcrypt-challenger-client.log" ]; then
echo " Log contents:"
cat /tmp/kcrypt-challenger-client.log
echo
else
echo " No log file found"
fi
echo "5. Test plugin mode (for comparison):"
echo " Command: echo '{\"data\": \"{\\\"name\\\": \\\"/dev/sda2\\\", \\\"uuid\\\": \\\"12345-abcde\\\", \\\"filesystemLabel\\\": \\\"encrypted-data\\\"}\"}' | ./kcrypt-discovery-challenger discovery.password"
echo " Expected: Same behavior as CLI mode"
echo
echo '{"data": "{\"name\": \"/dev/sda2\", \"uuid\": \"12345-abcde\", \"filesystemLabel\": \"encrypted-data\"}"}' | ./kcrypt-discovery-challenger discovery.password 2>&1 || true
echo
echo "=== Summary ==="
echo "✅ CLI interface successfully created"
echo "✅ Full compatibility with plugin mode maintained"
echo "✅ Same backend logic used for both interfaces"
echo "✅ Flow detection works in both modes"
echo ""
echo "Benefits:"
echo "- Much easier testing during development"
echo "- Can be used for debugging in production"
echo "- Clear command-line interface with help and examples"
echo "- Maintains full compatibility with kcrypt integration"

89
go.mod
View File

@@ -2,27 +2,21 @@ module github.com/kairos-io/kairos-challenger
go 1.25
replace github.com/kairos-io/tpm-helpers => github.com/kairos-io/tpm-helpers v0.0.0-20251002141416-c1a8f4118cdc
//replace github.com/kairos-io/tpm-helpers => /home/dimitris/workspace/kairos/tpm-helpers
require (
github.com/go-logr/logr v1.4.3
github.com/google/go-attestation v0.5.1
github.com/google/go-tpm v0.9.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/jaypipes/ghw v0.19.1
github.com/kairos-io/kairos-sdk v0.11.1-0.20251017100730-afc293496b30
github.com/kairos-io/tpm-helpers v0.0.0-20240123063624-f7a3fcc66199
github.com/kairos-io/kairos-sdk v0.11.0
github.com/kairos-io/tpm-helpers v0.0.0-20250917111550-e914e08a09c2
github.com/mudler/go-pluggable v0.0.0-20230126220627-7710299a0ae5
github.com/mudler/go-processmanager v0.0.0-20240820160718-8b802d3ecf82
github.com/mudler/yip v1.18.0
github.com/onsi/ginkgo/v2 v2.25.3
github.com/mudler/yip v1.18.1
github.com/onsi/ginkgo/v2 v2.26.0
github.com/onsi/gomega v1.38.2
github.com/pkg/errors v0.9.1
github.com/spectrocloud/peg v0.0.0-20240405075800-c5da7125e30f
github.com/spf13/cobra v1.10.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.27.2
k8s.io/apimachinery v0.27.4
@@ -31,21 +25,49 @@ require (
)
require (
atomicgo.dev/cursor v0.2.0 // indirect
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.12.9 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/avast/retry-go v3.0.0+incompatible // indirect
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bramvdbogaerde/go-scp v1.2.1 // indirect
github.com/cavaliergopher/grab/v3 v3.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chuckpreslar/emission v0.0.0-20170206194824-a7ddd980baf9 // indirect
github.com/codingsince1985/checksum v1.2.6 // indirect
github.com/containerd/cgroups/v3 v3.0.5 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/containerd/containerd v1.7.28 // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denisbrodbeck/machineid v1.0.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/emicklei/go-restful/v3 v3.10.1 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/folbricht/tpmk v0.1.2-0.20230104073416-f20b20c289d7 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.2.4 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
@@ -57,54 +79,81 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/certificate-transparency-go v1.1.4 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-attestation v0.4.4-0.20220404204839-8820d49b18d9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-configfs-tsm v0.3.3 // indirect
github.com/google/go-tpm-tools v0.4.4 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/go-tpm v0.3.3 // indirect
github.com/google/go-tpm-tools v0.3.10 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.15 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/ipfs/go-log v1.0.5 // indirect
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/itchyny/gojq v0.12.17 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jaypipes/pcidb v1.1.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/miekg/dns v1.1.55 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/prometheus/client_golang v1.20.2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/pterm/pterm v0.12.81 // indirect
github.com/qeesung/image2ascii v1.0.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/shirou/gopsutil/v4 v4.24.7 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4-0.20250804143300-cb253f3080f1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twpayne/go-vfs/v4 v4.3.0 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/wayneashleyberry/terminal-dimensions v1.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.24.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.42.0 // indirect
@@ -118,6 +167,8 @@ require (
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.37.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

843
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1,104 +0,0 @@
## Attestation Package
This package implements a clean, close-circle API for the kcrypt-challenger remote attestation flow. It separates client and server responsibilities and defines the wire messages used between them, so consumers can simply pass bytes from one step to the next and only check for errors in between.
Important: The client decides which PCRs to include/enroll/validate. The server validates according to its selective enrollment policy. Nonces are not used in this flow (sequential, authenticated channel).
### High-Level Flow
Client (node with TPM):
1) CreateInit → send to server
2) Receive AttestationChallenge
3) HandleChallenge → send AttestationProof to server
Server (kcrypt-challenger):
1) ParseInit
2) GenerateChallenge (EK+AK only; PCRs not needed here)
3) VerifyProof (secret + PCR quote signature verification + PCR consistency verification) → proceed with selective enrollment and passphrase release
### Wire Messages (owned by this package)
- AttestationInit: EK (PEM/DER), AK AttestationParameters, optional AK public
- AttestationChallenge: EncryptedCredential (no PCRs)
- AttestationProof: Secret (credential activation), PCRQuote (JSON with quote + selected PCR values)
### Client API
- NewRemoteAttestationClient(opts ...tpm.Option) (*RemoteAttestationClient, error)
- Close() error
- CreateInit() ([]byte, error)
- HandleChallenge(challengeBytes []byte) ([]byte, error)
Internally, the client uses transient AKs (no persistent AK storage). It sends EK + AK attestation data in AttestationInit, and later returns the secret and PCR quote in AttestationProof.
### Server API
- NewRemoteAttestationServer(attestator Attestator) *RemoteAttestationServer
- ParseInit(initBytes []byte) (ek *attest.EK, akParams *attest.AttestationParameters, err error)
- GenerateChallenge(initBytes []byte) (challengeBytes []byte, secret []byte, err error)
- VerifyProof(initBytes []byte, proofBytes []byte, expectedSecret []byte) (VerificationResult, error)
- IssuePassphrase(initBytes []byte, proofBytes []byte, expectedSecret []byte) ([]byte, error)
The server accepts AttestationInit, generates an EncryptedCredential (challenge) bound to EK+AK params (no PCRs), and later verifies the secret and the PCR quote signature using the AK public contained within the attestation parameters. The server also verifies that the provided PCR values are consistent with the TPM quote to ensure they are cryptographically bound to the quote. After verification, it delegates policy/enrollment to the injected Attestator and returns a passphrase or error.
### Selective Enrollment
This package does not implement enrollment. The server injects an Attestator which receives final verified attestation data and decides enrollment/validation and passphrase issuance. Typical policies (for reference):
- Empty value: accept any, update stored value (re-enrollment)
- Set value: enforce exact match (strict)
- Omitted: skip entirely
**Note on Initial Enrollment Scenarios:**
The Attestator implementation (e.g., ChallengerAttestator in pkg/challenger/) may support additional enrollment scenarios when a SealedVolume exists without attestation data:
1. **Static passphrase setup (recommended)**:
- Operator creates: SealedVolume (no attestation) + Secret (pre-defined passphrase) + partition references Secret
- System learns: EK, AK, all PCRs via TOFU on first connection
- Secret: Pre-defined, controlled by operator
2. **Secret reuse**:
- Operator recreates SealedVolume (no attestation) after deletion, partition references existing Secret
- System learns: EK, AK, all PCRs via TOFU
- Secret: Reused from previous enrollment
3. **Deferred TOFU (edge case, not recommended)**:
- Operator creates: SealedVolume (no attestation) + no Secret reference in partition
- System creates: Secret (auto-generated passphrase) + learns EK, AK, all PCRs
- Secret: Auto-generated, operator has no control
- ⚠️ WARNING: This is unusual. If you want TOFU, let the system create the entire SealedVolume. If you pre-create a SealedVolume, you probably want to control the passphrase (scenario 1).
4. **Partial attestation data (selective enrollment)**:
- Operator creates: SealedVolume with specific PCR entries (empty or set) + Secret
- System tracks: Only specified PCRs, omitted PCRs are ignored entirely
- Secret: Pre-defined or referenced
### Implementation Notes
- EK→AK binding is proven by successful credential activation (no separate AK certification step required).
- PCR quote structure contains both quote/signature and the actual selected PCR values.
- PCR quote signature is cryptographically verified using the AK public key to ensure PCR authenticity.
- PCR values are verified against the TPM quote digest to ensure they are cryptographically bound to the quote.
- This package owns message marshalling/unmarshalling so consumers don't need to manage encoding details.
### Attestator (Injected Policy)
The Attestator is provided by the consumer and is called after cryptographic verification succeeds. It decides selective enrollment/validation and returns the passphrase.
Interface (conceptual):
```
type Attestator interface {
IssuePassphrase(ctx context.Context, req AttestationRequest) ([]byte, error)
}
type AttestationRequest struct {
TPMHash string // derived from EK (server-enrolled identity)
PCRs map[int][]byte // client-selected PCRs from verified quote
EKPEM []byte // optional: EK in PEM/DER for auditing/forensics
// Optional: partition/volume metadata for policy, if the consumer passes it through
}
```
Notes:
- AK public is not passed to the Attestator. The library verifies the EK→AK→PCR chain and only then calls the Attestator with data relevant to policy (PCRs, TPM identity).
### Next Steps
- Implement Ginkgo tests that cover: init/challenge/proof happy path, invalid credentials, PCR set variations, selective enrollment behaviors.
- Refactor kcrypt-challenger to use this package end-to-end.
- Then clean up flow logic in tpm-helpers, keeping only low-level helpers.

View File

@@ -1,13 +0,0 @@
package attestation_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestAttestation(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Attestation Suite")
}

View File

@@ -1,88 +0,0 @@
package attestation_test
import (
"context"
"encoding/json"
"github.com/google/go-attestation/attest"
. "github.com/kairos-io/kairos-challenger/pkg/attestation"
tpmhelpers "github.com/kairos-io/tpm-helpers"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type dummyAttestator struct{}
func (d *dummyAttestator) IssuePassphrase(ctx context.Context, req AttestationRequest) ([]byte, error) {
return []byte("passphrase"), nil
}
var _ = Describe("Remote attestation end-to-end", func() {
It("client and server roundtrip", func() {
// Client: create init
client, err := NewRemoteAttestationClient(tpmhelpers.Emulated, tpmhelpers.EmulatedHostSeed())
Expect(err).ToNot(HaveOccurred())
defer client.Close() //nolint:errcheck
initBytes, err := client.CreateInit()
Expect(err).ToNot(HaveOccurred())
Expect(initBytes).ToNot(BeEmpty())
// Server: parse init and generate challenge
server := NewRemoteAttestationServer(&dummyAttestator{})
challengeBytes, expectedSecret, err := server.GenerateChallenge(initBytes)
Expect(err).ToNot(HaveOccurred())
Expect(challengeBytes).ToNot(BeEmpty())
Expect(expectedSecret).ToNot(BeEmpty())
// Client: handle challenge with chosen PCRs
proofBytes, err := client.HandleChallenge(challengeBytes, []int{0, 7, 11})
Expect(err).ToNot(HaveOccurred())
Expect(proofBytes).ToNot(BeEmpty())
// Server: verify proof and issue passphrase
vr, err := server.VerifyProof(initBytes, proofBytes, expectedSecret)
Expect(err).ToNot(HaveOccurred())
Expect(vr.PCRs).ToNot(BeNil())
// Issue passphrase via attestator
pass, err := server.IssuePassphrase(context.Background(), initBytes, proofBytes, expectedSecret)
Expect(err).ToNot(HaveOccurred())
Expect(pass).To(Equal([]byte("passphrase")))
})
It("methods validate input formats", func() {
// malformed init
server := NewRemoteAttestationServer(&dummyAttestator{})
_, _, err := server.GenerateChallenge([]byte("not-json"))
Expect(err).To(HaveOccurred())
// client handle malformed challenge
client, err := NewRemoteAttestationClient(tpmhelpers.Emulated, tpmhelpers.EmulatedHostSeed())
Expect(err).ToNot(HaveOccurred())
defer client.Close() //nolint:errcheck
_, err = client.HandleChallenge([]byte("not-json"), []int{0})
Expect(err).To(HaveOccurred())
})
It("server parse init decodes AK params", func() {
client, err := NewRemoteAttestationClient(tpmhelpers.Emulated, tpmhelpers.EmulatedHostSeed())
Expect(err).ToNot(HaveOccurred())
defer client.Close() //nolint:errcheck
initBytes, err := client.CreateInit()
Expect(err).ToNot(HaveOccurred())
ek, akParams, err := NewRemoteAttestationServer(&dummyAttestator{}).ParseInit(initBytes)
Expect(err).ToNot(HaveOccurred())
Expect(ek).ToNot(BeNil())
Expect(akParams).ToNot(BeNil())
// ensure akParams is valid
var ap attest.AttestationParameters
akParamsBytes, err := json.Marshal(akParams)
Expect(err).ToNot(HaveOccurred())
Expect(json.Unmarshal(akParamsBytes, &ap)).To(Succeed())
})
})

View File

@@ -1,90 +0,0 @@
package attestation
import (
"encoding/json"
"github.com/google/go-attestation/attest"
tpmhelpers "github.com/kairos-io/tpm-helpers"
)
type RemoteAttestationClient struct {
akm *tpmhelpers.AKManager
}
func NewRemoteAttestationClient(opts ...tpmhelpers.Option) (*RemoteAttestationClient, error) {
akm, err := tpmhelpers.NewAKManager(opts...)
if err != nil {
return nil, err
}
return &RemoteAttestationClient{akm: akm}, nil
}
func (c *RemoteAttestationClient) Close() error {
return c.akm.Close()
}
// CreateInit gathers EK and creates a transient AK, returning AttestationInit bytes
func (c *RemoteAttestationClient) CreateInit() ([]byte, error) {
// Get attestation params of cached AK
params, err := c.akm.AKParams()
if err != nil {
return nil, err
}
// Get EK
ek, err := c.akm.GetEK()
if err != nil {
return nil, err
}
// Marshal AK params
akParamsBytes, err := json.Marshal(params)
if err != nil {
return nil, err
}
ekSPKI, err := EncodePublicKeyToSPKI(ek.Public)
if err != nil {
return nil, err
}
init := AttestationInit{
EKPublic: ekSPKI,
AKParams: akParamsBytes,
}
return json.Marshal(init)
}
// HandleChallenge takes an AttestationChallenge (bytes), activates credential, and returns AttestationProof bytes
// The client selects PCRs.
func (c *RemoteAttestationClient) HandleChallenge(challengeBytes []byte, pcrs []int) ([]byte, error) {
var ch AttestationChallenge
if err := json.Unmarshal(challengeBytes, &ch); err != nil {
return nil, err
}
var ec struct{}
if err := json.Unmarshal(ch.EncryptedCredential, &ec); err != nil {
return nil, err
}
// Activate credential to get secret
// Unmarshal EncryptedCredential into the right type
var enc attest.EncryptedCredential
if err := json.Unmarshal(ch.EncryptedCredential, &enc); err != nil {
return nil, err
}
secret, err := c.akm.ActivateCredential(&enc)
if err != nil {
return nil, err
}
// Generate PCR quote
pcrQuote, err := c.akm.GeneratePCRQuote(pcrs)
if err != nil {
return nil, err
}
proof := AttestationProof{Secret: secret, PCRQuote: pcrQuote}
return json.Marshal(proof)
}

View File

@@ -1,31 +0,0 @@
package attestation
import (
"crypto/x509"
"encoding/json"
)
// AttestationInit is sent by the client at the start of the flow.
// EKPublic is the SPKI-encoded public key (PKIX) of the EK.
// AKParams are the attestation parameters of the transient AK.
type AttestationInit struct {
EKPublic []byte `json:"ek_public"`
AKParams json.RawMessage `json:"ak_params"` // marshaled attest.AttestationParameters
}
// AttestationChallenge is returned by the server: a JSON-encoded attest.EncryptedCredential
type AttestationChallenge struct {
EncryptedCredential json.RawMessage `json:"challenge"`
}
// AttestationProof is returned by the client: secret + PCR quote payload
// PCRQuote is a JSON payload: { quote:{version,quote,signature}, pcrs:{index:value} }
type AttestationProof struct {
Secret []byte `json:"secret"`
PCRQuote json.RawMessage `json:"pcr_quote"`
}
// EncodePublicKeyToSPKI returns DER-encoded SubjectPublicKeyInfo for a public key
func EncodePublicKeyToSPKI(pub interface{}) ([]byte, error) {
return x509.MarshalPKIXPublicKey(pub)
}

View File

@@ -1,250 +0,0 @@
package attestation
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"github.com/google/go-attestation/attest"
"github.com/google/go-tpm/tpm2"
tpm "github.com/kairos-io/tpm-helpers"
)
type AttestationRequest struct {
TPMHash string
PCRs map[int][]byte
EKPEM []byte
}
type Attestator interface {
IssuePassphrase(ctx context.Context, req AttestationRequest) ([]byte, error)
}
type VerificationResult struct {
AKPublic crypto.PublicKey // for internal checks/logging if needed by caller
PCRs map[int][]byte
}
type RemoteAttestationServer struct {
attestator Attestator
}
func NewRemoteAttestationServer(attestator Attestator) *RemoteAttestationServer {
return &RemoteAttestationServer{attestator: attestator}
}
func (s *RemoteAttestationServer) ParseInit(initBytes []byte) (*attest.EK, *attest.AttestationParameters, error) {
var init AttestationInit
if err := json.Unmarshal(initBytes, &init); err != nil {
return nil, nil, err
}
// Decode EK public from SPKI DER
ekPub, err := x509.ParsePKIXPublicKey(init.EKPublic)
if err != nil {
return nil, nil, fmt.Errorf("parse EK SPKI: %w", err)
}
ek := &attest.EK{Public: ekPub}
// Unmarshal AK params
var params attest.AttestationParameters
if err := json.Unmarshal(init.AKParams, &params); err != nil {
return nil, nil, err
}
return ek, &params, nil
}
func (s *RemoteAttestationServer) GenerateChallenge(initBytes []byte) ([]byte, []byte, error) {
ek, akParams, err := s.ParseInit(initBytes)
if err != nil {
return nil, nil, err
}
ap := attest.ActivationParameters{TPMVersion: attest.TPMVersion20, EK: ek.Public, AK: *akParams}
secret, ec, err := ap.Generate()
if err != nil {
return nil, nil, err
}
chBytes, err := json.Marshal(ec)
if err != nil {
return nil, nil, err
}
ch := AttestationChallenge{EncryptedCredential: chBytes}
out, err := json.Marshal(ch)
if err != nil {
return nil, nil, err
}
return out, secret, nil
}
func (s *RemoteAttestationServer) VerifyProof(initBytes, proofBytes, expectedSecret []byte) (VerificationResult, error) {
var proof AttestationProof
if err := json.Unmarshal(proofBytes, &proof); err != nil {
return VerificationResult{}, err
}
if !equalBytes(expectedSecret, proof.Secret) {
return VerificationResult{}, fmt.Errorf("invalid secret")
}
// Get AK public from init's AK params
_, akParams, err := s.ParseInit(initBytes)
if err != nil {
return VerificationResult{}, err
}
// Convert AK public to crypto.PublicKey
akPub, err := akPublicFromParams(akParams)
if err != nil {
return VerificationResult{}, err
}
// Parse and verify the PCR quote
var pq struct {
Quote struct {
Version string `json:"version"`
Quote []byte `json:"quote"`
Signature []byte `json:"signature"`
} `json:"quote"`
PCRs map[int][]byte `json:"pcrs"`
}
if err := json.Unmarshal(proof.PCRQuote, &pq); err != nil {
return VerificationResult{}, fmt.Errorf("unmarshaling PCR quote: %w", err)
}
// Verify the PCR quote signature and extract verified PCR values using tpm-helpers
verifiedPCRs, err := tpm.VerifyPCRQuote(proof.PCRQuote, akPub)
if err != nil {
return VerificationResult{}, fmt.Errorf("PCR quote verification failed: %w", err)
}
return VerificationResult{AKPublic: akPub, PCRs: verifiedPCRs}, nil
}
func (s *RemoteAttestationServer) IssuePassphrase(ctx context.Context, initBytes, proofBytes, expectedSecret []byte) ([]byte, error) {
vr, err := s.VerifyProof(initBytes, proofBytes, expectedSecret)
if err != nil {
return nil, err
}
// Derive TPM hash from EK again for input to Attestator
ek, _, err := s.ParseInit(initBytes)
if err != nil {
return nil, err
}
tpmHash, err := ComputeTPMHashFromEK(ek)
if err != nil {
return nil, err
}
// Encode EK to PEM for the attestator
ekPEM, err := EncodeEKToPEM(ek)
if err != nil {
return nil, fmt.Errorf("encoding EK to PEM: %w", err)
}
// Build request for Attestator
req := AttestationRequest{
TPMHash: tpmHash,
PCRs: vr.PCRs,
EKPEM: ekPEM,
}
return s.attestator.IssuePassphrase(ctx, req)
}
// Helpers
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func ComputeTPMHashFromEK(ek *attest.EK) (string, error) {
spki, err := x509.MarshalPKIXPublicKey(ek.Public)
if err != nil {
return "", fmt.Errorf("marshal EK: %w", err)
}
sum := sha256.Sum256(spki)
return fmt.Sprintf("%x", sum[:]), nil
}
func akPublicFromParams(params *attest.AttestationParameters) (crypto.PublicKey, error) {
pub, err := tpm2.Unmarshal[tpm2.TPMTPublic](params.Public)
if err != nil {
return nil, fmt.Errorf("unmarshal TPM public: %w", err)
}
switch pub.Type {
case tpm2.TPMAlgRSA:
rsaParms, err := pub.Parameters.RSADetail()
if err != nil {
return nil, fmt.Errorf("rsa params: %w", err)
}
rsaUnique, err := pub.Unique.RSA()
if err != nil {
return nil, fmt.Errorf("rsa unique: %w", err)
}
n := new(big.Int).SetBytes(rsaUnique.Buffer)
e := int(rsaParms.Exponent)
if e == 0 {
e = 65537
}
return &rsa.PublicKey{N: n, E: e}, nil
case tpm2.TPMAlgECC:
eccParms, err := pub.Parameters.ECCDetail()
if err != nil {
return nil, fmt.Errorf("ecc params: %w", err)
}
eccUnique, err := pub.Unique.ECC()
if err != nil {
return nil, fmt.Errorf("ecc unique: %w", err)
}
var curve elliptic.Curve
switch eccParms.CurveID {
case tpm2.TPMECCNistP256:
curve = elliptic.P256()
case tpm2.TPMECCNistP384:
curve = elliptic.P384()
case tpm2.TPMECCNistP521:
curve = elliptic.P521()
default:
return nil, fmt.Errorf("unsupported curve: %v", eccParms.CurveID)
}
x := new(big.Int).SetBytes(eccUnique.X.Buffer)
y := new(big.Int).SetBytes(eccUnique.Y.Buffer)
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
default:
return nil, fmt.Errorf("unsupported key type: %v", pub.Type)
}
}
// EncodeEKToPEM encodes an EK to PEM format
func EncodeEKToPEM(ek *attest.EK) ([]byte, error) {
if ek.Certificate != nil {
// If we have a certificate, encode it as PEM
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: ek.Certificate.Raw,
}
return pem.EncodeToMemory(pemBlock), nil
}
// Otherwise, encode the public key as PEM
data, err := x509.MarshalPKIXPublicKey(ek.Public)
if err != nil {
return nil, fmt.Errorf("marshaling EK public key: %w", err)
}
pemBlock := &pem.Block{
Type: "PUBLIC KEY",
Bytes: data,
}
return pem.EncodeToMemory(pemBlock), nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,27 +5,11 @@
package challenger
import (
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"github.com/go-logr/logr"
"github.com/google/go-attestation/attest"
keyserverv1alpha1 "github.com/kairos-io/kairos-challenger/api/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// generateTestRSAPublicKey generates a dummy RSA public key for testing
func generateTestRSAPublicKey() *rsa.PublicKey {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic("Failed to generate test RSA key: " + err.Error())
}
return &privateKey.PublicKey
}
var _ = Describe("challenger", func() {
Describe("findSecretFor", func() {
var requestData PassphraseRequestData
@@ -54,7 +38,7 @@ var _ = Describe("challenger", func() {
})
It("returns the sealed volume data", func() {
volumeData, _ := findVolumeFor(requestData, volumeList)
volumeData := findVolumeFor(requestData, volumeList)
Expect(volumeData).ToNot(BeNil())
Expect(volumeData.Quarantined).To(BeFalse())
Expect(volumeData.SecretName).To(Equal("the_secret"))
@@ -83,7 +67,7 @@ var _ = Describe("challenger", func() {
})
It("doesn't match a request with an empty field", func() {
volumeData, _ := findVolumeFor(requestData, volumeList)
volumeData := findVolumeFor(requestData, volumeList)
Expect(volumeData).To(BeNil())
})
})
@@ -102,7 +86,7 @@ var _ = Describe("challenger", func() {
})
It("returns the sealed volume data", func() {
volumeData, _ := findVolumeFor(requestData, volumeList)
volumeData := findVolumeFor(requestData, volumeList)
Expect(volumeData).ToNot(BeNil())
Expect(volumeData.Quarantined).To(BeFalse())
Expect(volumeData.SecretName).To(Equal("the_secret"))
@@ -124,7 +108,7 @@ var _ = Describe("challenger", func() {
})
It("returns the sealed volume data", func() {
volumeData, _ := findVolumeFor(requestData, volumeList)
volumeData := findVolumeFor(requestData, volumeList)
Expect(volumeData).ToNot(BeNil())
Expect(volumeData.Quarantined).To(BeFalse())
Expect(volumeData.SecretName).To(Equal("the_secret"))
@@ -146,382 +130,11 @@ var _ = Describe("challenger", func() {
})
It("returns nil sealedVolumeData", func() {
volumeData, _ := findVolumeFor(requestData, volumeList)
volumeData := findVolumeFor(requestData, volumeList)
Expect(volumeData).To(BeNil())
})
})
})
Describe("Selective Enrollment Mode", func() {
var logger logr.Logger
BeforeEach(func() {
logger = logr.Discard()
})
Describe("verifyPCRValuesSelective", func() {
var currentPCRs *keyserverv1alpha1.PCRValues
const expectedPCR0 = "abc123def456"
const expectedPCR7 = "ghi789jkl012"
const expectedPCR11 = "mno345pqr678"
BeforeEach(func() {
currentPCRs = &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": expectedPCR0,
"7": expectedPCR7,
"11": expectedPCR11,
},
}
})
When("stored PCR values are empty (re-enrollment mode)", func() {
It("should accept any PCR values during verification", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "", // Empty = re-enrollment mode
"7": "", // Empty = re-enrollment mode
"11": "", // Empty = re-enrollment mode
},
}
err := verifyPCRValuesSelective(storedPCRs, currentPCRs, logger)
Expect(err).To(BeNil())
})
It("should store the current PCR values during re-enrollment", func() {
attestation := &keyserverv1alpha1.AttestationSpec{
PCRValues: &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "", // Empty = re-enrollment mode
"7": "", // Empty = re-enrollment mode
"11": "", // Empty = re-enrollment mode
},
},
}
// Before re-enrollment: PCRs should be empty
Expect(attestation.PCRValues.PCRs["0"]).To(Equal(""))
Expect(attestation.PCRValues.PCRs["7"]).To(Equal(""))
Expect(attestation.PCRValues.PCRs["11"]).To(Equal(""))
// Re-enrollment should store the current PCR values
clientAttestation := &ClientAttestation{
EK: &attest.EK{Public: generateTestRSAPublicKey()},
PCRValues: currentPCRs,
}
err := updateAttestationDataSelective(attestation, clientAttestation, logger)
Expect(err).To(BeNil())
// After re-enrollment: PCRs should be stored with exact expected values
Expect(attestation.PCRValues.PCRs["0"]).To(Equal(expectedPCR0))
Expect(attestation.PCRValues.PCRs["7"]).To(Equal(expectedPCR7))
Expect(attestation.PCRValues.PCRs["11"]).To(Equal(expectedPCR11))
})
It("should transition from re-enrollment mode to enforcement mode", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "", // Start in re-enrollment mode
},
}
// Create a limited current PCR set (only PCR0) to test selective enrollment
limitedCurrentPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": expectedPCR0, // Only provide PCR0
},
}
// Step 1: Should accept any PCR values (re-enrollment mode)
err := verifyPCRValuesSelective(storedPCRs, limitedCurrentPCRs, logger)
Expect(err).To(BeNil())
// Step 2: Re-enroll - store the PCR value (should only update the empty PCR0)
attestation := &keyserverv1alpha1.AttestationSpec{
EKPublicKey: "existing-ek", // Set so it won't try to update
PCRValues: storedPCRs,
}
clientAttestation := &ClientAttestation{
EK: &attest.EK{Public: generateTestRSAPublicKey()},
PCRValues: limitedCurrentPCRs,
}
err = updateAttestationDataSelective(attestation, clientAttestation, logger)
Expect(err).To(BeNil())
// Verify PCR0 was enrolled and no other PCRs were added
Expect(storedPCRs.PCRs["0"]).To(Equal(expectedPCR0))
Expect(storedPCRs.PCRs).To(HaveLen(1)) // Should still only have PCR0
// Step 3: Now should be in enforcement mode - same PCR should pass
err = verifyPCRValuesSelective(storedPCRs, limitedCurrentPCRs, logger)
Expect(err).To(BeNil())
// Step 4: Different PCR should now fail (enforcement mode)
differentPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "different_value",
},
}
err = verifyPCRValuesSelective(storedPCRs, differentPCRs, logger)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("PCR0 changed"))
})
})
When("stored PCR values are set (enforcement mode)", func() {
It("should enforce exact match for set values", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "abc123def456", // Matches current
"7": "different_value", // Different from current
"11": "mno345pqr678", // Matches current
},
}
err := verifyPCRValuesSelective(storedPCRs, currentPCRs, logger)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("PCR7 changed"))
})
It("should pass when all set values match", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "abc123def456", // Matches current
"7": "ghi789jkl012", // Matches current
"11": "mno345pqr678", // Matches current
},
}
err := verifyPCRValuesSelective(storedPCRs, currentPCRs, logger)
Expect(err).To(BeNil())
})
})
When("PCR fields are omitted (skip verification)", func() {
It("should skip verification for omitted PCRs entirely", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "abc123def456", // Present and matches
"7": "ghi789jkl012", // Present and matches
// "11" is omitted entirely = skip verification
},
}
err := verifyPCRValuesSelective(storedPCRs, currentPCRs, logger)
Expect(err).To(BeNil())
})
})
When("mixed selective and enforcement mode", func() {
It("should handle combination of empty, set, and omitted PCRs", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "", // Empty = re-enrollment mode
"7": "ghi789jkl012", // Set = enforcement mode (matches)
"14": "any_value", // Set but PCR14 not in current (should fail)
// "11" omitted = skip verification
},
}
err := verifyPCRValuesSelective(storedPCRs, currentPCRs, logger)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("PCR14"))
})
})
When("no stored PCR values exist", func() {
It("should accept any current PCR values", func() {
err := verifyPCRValuesSelective(nil, currentPCRs, logger)
Expect(err).To(BeNil())
})
})
When("no current PCR values provided", func() {
It("should pass if no stored values either", func() {
err := verifyPCRValuesSelective(nil, nil, logger)
Expect(err).To(BeNil())
})
It("should fail if stored values expect specific PCRs", func() {
storedPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "abc123def456",
},
}
err := verifyPCRValuesSelective(storedPCRs, nil, logger)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no current PCR values"))
})
})
})
Describe("updateAttestationDataSelective", func() {
It("should update empty EK field with current value", func() {
// Set up test data - EK in re-enrollment mode (empty)
attestation := &keyserverv1alpha1.AttestationSpec{
EKPublicKey: "", // Empty = should be updated
}
// Create a mock EK with a real RSA public key
mockEK := &attest.EK{
Public: generateTestRSAPublicKey(),
}
clientAttestation := &ClientAttestation{
EK: mockEK,
}
// Call the function under test
err := updateAttestationDataSelective(attestation, clientAttestation, logger)
Expect(err).To(BeNil())
// Check results: EK should be updated (was empty)
Expect(attestation.EKPublicKey).ToNot(Equal(""))
})
It("should NOT update set EK field", func() {
// Set up test data - EK already set
existingEK := "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----"
attestation := &keyserverv1alpha1.AttestationSpec{
EKPublicKey: existingEK, // Set = should NOT be updated
}
mockEK := &attest.EK{
Public: generateTestRSAPublicKey(),
}
clientAttestation := &ClientAttestation{
EK: mockEK,
}
// Call the function under test
err := updateAttestationDataSelective(attestation, clientAttestation, logger)
Expect(err).To(BeNil())
// Check results: EK should NOT be updated (was already set)
Expect(attestation.EKPublicKey).To(Equal(existingEK))
})
It("should update empty PCR fields with current values", func() {
// Set up test data
currentPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "new_pcr0_value",
"7": "new_pcr7_value",
"11": "new_pcr11_value",
},
}
attestation := &keyserverv1alpha1.AttestationSpec{
EKPublicKey: "existing-ek", // Set so it won't try to update
PCRValues: &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "", // Empty = should be updated
"7": "fixed_pcr7_value", // Set = should NOT be updated
"11": "", // Empty = should be updated
},
},
}
// Call the function under test
clientAttestation := &ClientAttestation{
EK: &attest.EK{Public: generateTestRSAPublicKey()},
PCRValues: currentPCRs,
}
err := updateAttestationDataSelective(attestation, clientAttestation, logger)
Expect(err).To(BeNil())
// Check results: PCR0 should be updated (was empty)
Expect(attestation.PCRValues.PCRs["0"]).To(Equal("new_pcr0_value"))
// Check results: PCR7 should NOT be updated (was set)
Expect(attestation.PCRValues.PCRs["7"]).To(Equal("fixed_pcr7_value"))
// Check results: PCR11 should be updated (was empty)
Expect(attestation.PCRValues.PCRs["11"]).To(Equal("new_pcr11_value"))
})
})
Describe("Initial TOFU Enrollment behavior", func() {
It("should store ALL provided PCRs during initial enrollment", func() {
clientPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"0": "pcr0_value",
"1": "pcr1_value",
"2": "pcr2_value",
"7": "pcr7_value",
"11": "pcr11_value",
"14": "pcr14_value",
},
}
attestation := createInitialTOFUAttestation(clientPCRs, logger)
// All provided PCRs should be stored
Expect(attestation.PCRValues).ToNot(BeNil())
Expect(attestation.PCRValues.PCRs).To(HaveLen(6))
Expect(attestation.PCRValues.PCRs["0"]).To(Equal("pcr0_value"))
Expect(attestation.PCRValues.PCRs["1"]).To(Equal("pcr1_value"))
Expect(attestation.PCRValues.PCRs["2"]).To(Equal("pcr2_value"))
Expect(attestation.PCRValues.PCRs["7"]).To(Equal("pcr7_value"))
Expect(attestation.PCRValues.PCRs["11"]).To(Equal("pcr11_value"))
Expect(attestation.PCRValues.PCRs["14"]).To(Equal("pcr14_value"))
})
It("should not filter or omit any PCRs during TOFU", func() {
// Test that even "sensitive" PCRs like PCR11 are stored
clientPCRs := &keyserverv1alpha1.PCRValues{
PCRs: map[string]string{
"11": "kernel_pcr_value", // Previously filtered out
"12": "other_pcr_value",
},
}
attestation := createInitialTOFUAttestation(clientPCRs, logger)
Expect(attestation.PCRValues.PCRs).To(HaveKey("11"))
Expect(attestation.PCRValues.PCRs).To(HaveKey("12"))
Expect(attestation.PCRValues.PCRs["11"]).To(Equal("kernel_pcr_value"))
})
})
})
Describe("handleTPMAttestation functions", func() {
Describe("establishAttestationConnection", func() {
var mockResponseWriter *httptest.ResponseRecorder
var mockRequest *http.Request
var logger logr.Logger
BeforeEach(func() {
logger = logr.Discard()
mockResponseWriter = httptest.NewRecorder()
mockRequest = httptest.NewRequest("GET", "/test", nil)
// Set partition headers
mockRequest.Header.Set("label", "COS_PERSISTENT")
mockRequest.Header.Set("name", "/dev/sda1")
mockRequest.Header.Set("uuid", "test-uuid-123")
})
It("should return error when WebSocket upgrade fails", func() {
// This test checks the error behavior when WebSocket upgrade fails
conn, partition, err := establishAttestationConnection(mockResponseWriter, mockRequest, logger)
// WebSocket upgrade should fail with regular HTTP request
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("upgrade"))
Expect(conn).To(BeNil())
// When upgrade fails, partition info is not extracted (function returns early)
Expect(partition.Label).To(Equal(""))
Expect(partition.DeviceName).To(Equal(""))
Expect(partition.UUID).To(Equal(""))
})
})
})
})
func volumeListWithPartitionSpec(partitionSpec keyserverv1alpha1.PartitionSpec) *keyserverv1alpha1.SealedVolumeList {
@@ -538,25 +151,3 @@ func volumeListWithPartitionSpec(partitionSpec keyserverv1alpha1.PartitionSpec)
},
}
}
func volumeListWithAttestationSpec(tpmHash string, attestation *keyserverv1alpha1.AttestationSpec) *keyserverv1alpha1.SealedVolumeList {
return &keyserverv1alpha1.SealedVolumeList{
Items: []keyserverv1alpha1.SealedVolume{
{Spec: keyserverv1alpha1.SealedVolumeSpec{
TPMHash: tpmHash,
Partitions: []keyserverv1alpha1.PartitionSpec{
{
Label: "COS_PERSISTENT",
Secret: &keyserverv1alpha1.SecretSpec{
Name: "test-secret",
Path: "pass",
},
},
},
Quarantined: false,
Attestation: attestation,
},
},
},
}
}

View File

@@ -7,7 +7,7 @@ import (
. "github.com/onsi/gomega"
)
func TestChallenger(t *testing.T) {
func TestEpinio(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Kcrypt challenger suite")
}

View File

@@ -2,8 +2,3 @@ package constants
const TPMSecret = "tpm"
const GeneratedByKey = "generated_by"
// TPM NV Index constants for storing encrypted data
// Using 0x1500000+ range to avoid reserved TPM manufacturer ranges (0x00000000-0x003FFFFF)
const LocalPassphraseNVIndex = "0x1500000" // For storing encrypted LUKS passphrase (offline mode)
// Note: AKBlobNVIndex removed - using transient AKs now, no persistent AK storage needed

View File

@@ -57,8 +57,4 @@ kubectl apply -k "$SCRIPT_DIR/../tests/assets/"
# https://stackoverflow.com/a/6752280
export KMS_ADDRESS="10.0.2.2.challenger.sslip.io"
# The tpm emulator needs CGO
# https://github.com/google/go-tpm-tools/blob/215e2ab8d3ee0a9aab1249e908313c2ecddd692e/simulator/internal/internal_cross.go#L19
export CGO_ENABLED=1
go run github.com/onsi/ginkgo/v2/ginkgo -v --nodes $GINKGO_NODES --label-filter $LABEL --fail-fast -r ./tests/

View File

@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"syscall"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -22,7 +21,7 @@ var installationOutput string
var vm VM
var mdnsVM VM
var _ = Describe("kcrypt encryption", Label("encryption-tests"), func() {
var _ = Describe("kcrypt encryption", func() {
var config string
var vmOpts VMOptions
var expectedInstallationSuccess bool
@@ -91,8 +90,6 @@ hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
@@ -103,14 +100,13 @@ install:
kcrypt:
challenger:
mdns: true
mdns: true
challenger_server: "http://%[1]s"
`, mdnsHostname)
})
AfterEach(func() {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName)
cmd := exec.Command("kubectl", "delete", "sealedvolume", tpmHash)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
@@ -146,8 +142,6 @@ hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
`
})
@@ -172,8 +166,6 @@ hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
@@ -192,8 +184,7 @@ kcrypt:
})
AfterEach(func() {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName)
cmd := exec.Command("kubectl", "delete", "sealedvolume", tpmHash)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
})
@@ -208,7 +199,7 @@ kcrypt:
// Expect a secret to be created
cmd := exec.Command("kubectl", "get", "secrets",
fmt.Sprintf("%s-cos-persistent", getSealedVolumeName(tpmHash)),
fmt.Sprintf("%s-cos-persistent", tpmHash),
"-o=go-template='{{.data.generated_by|base64decode}}'",
)
@@ -221,20 +212,12 @@ kcrypt:
// https://kairos.io/docs/advanced/partition_encryption/#scenario-static-keys
When("using a remote key management server (static keys)", Label("remote-static"), func() {
var tpmHash string
var sealedVolumeName string
var secretName string
var err error
BeforeEach(func() {
tpmHash, err = vm.Sudo("/system/discovery/kcrypt-discovery-challenger")
Expect(err).ToNot(HaveOccurred(), tpmHash)
tpmHash = strings.TrimSpace(tpmHash)
// Use safe Kubernetes names (TPM hash is 64 chars, exceeds 63 char limit)
sealedVolumeName = getSealedVolumeName(tpmHash)
secretName = fmt.Sprintf("%s-cos-persistent", sealedVolumeName)
By(fmt.Sprintf("Creating secret with name: %s", secretName))
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: v1
kind: Secret
@@ -243,22 +226,9 @@ metadata:
namespace: default
type: Opaque
stringData:
passphrase: "awesome-plaintext-passphrase"
`, secretName))
pass: "awesome-plaintext-passphrase"
`, tpmHash))
// Verify secret was created
By(fmt.Sprintf("Waiting for secret %s to be ready", secretName))
Eventually(func() bool {
exists := secretExists(secretName)
if !exists {
GinkgoWriter.Printf("Secret %s does not exist yet, waiting...\n", secretName)
}
return exists
}, 10*time.Second, 1*time.Second).Should(BeTrue(), fmt.Sprintf("Secret %s should exist", secretName))
By(fmt.Sprintf("Secret %s verified to exist", secretName))
By(fmt.Sprintf("Creating SealedVolume with name: %s, TPM hash: %s", sealedVolumeName, tpmHash[:16]+"..."))
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
@@ -266,14 +236,14 @@ metadata:
name: %[1]s
namespace: default
spec:
TPMHash: "%[2]s"
TPMHash: "%[1]s"
partitions:
- label: COS_PERSISTENT
secret:
name: %[3]s
path: passphrase
name: %[1]s
path: pass
quarantined: false
`, sealedVolumeName, tpmHash, secretName))
`, tpmHash))
config = fmt.Sprintf(`#cloud-config
@@ -281,8 +251,6 @@ hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
@@ -298,11 +266,11 @@ kcrypt:
})
AfterEach(func() {
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName)
cmd := exec.Command("kubectl", "delete", "sealedvolume", tpmHash)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
cmd = exec.Command("kubectl", "delete", "secret", secretName)
cmd = exec.Command("kubectl", "delete", "secret", tpmHash)
out, err = cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
})
@@ -318,23 +286,31 @@ kcrypt:
})
})
When("the certificate is pinned on the configuration", Label("remote-https-pinned"), func() {
When("the key management server is listening on https", func() {
var tpmHash string
BeforeEach(func() {
tpmHash = createTPMPassphraseSecret(vm)
cert := getChallengerServerCert()
kcryptConfig := createConfigWithCert(fmt.Sprintf("https://%s", os.Getenv("KMS_ADDRESS")), cert)
kcryptConfigBytes, err := yaml.Marshal(kcryptConfig)
Expect(err).ToNot(HaveOccurred())
config = fmt.Sprintf(`#cloud-config
})
AfterEach(func() {
cmd := exec.Command("kubectl", "delete", "sealedvolume", tpmHash)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
})
When("the certificate is pinned on the configuration", Label("remote-https-pinned"), func() {
BeforeEach(func() {
cert := getChallengerServerCert()
kcryptConfig := createConfigWithCert(fmt.Sprintf("https://%s", os.Getenv("KMS_ADDRESS")), cert)
kcryptConfigBytes, err := yaml.Marshal(kcryptConfig)
Expect(err).ToNot(HaveOccurred())
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
@@ -346,40 +322,28 @@ install:
%s
`, string(kcryptConfigBytes))
})
It("successfully talks to the server", func() {
vm.Reboot()
vm.EventuallyConnects(1200)
out, err := vm.Sudo("blkid")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("TYPE=\"crypto_LUKS\" PARTLABEL=\"persistent\""), out)
Expect(out).To(MatchRegexp("/dev/mapper.*LABEL=\"COS_PERSISTENT\""), out)
})
})
It("successfully talks to the server", func() {
vm.Reboot()
vm.EventuallyConnects(1200)
out, err := vm.Sudo("blkid")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("TYPE=\"crypto_LUKS\" PARTLABEL=\"persistent\""), out)
Expect(out).To(MatchRegexp("/dev/mapper.*LABEL=\"COS_PERSISTENT\""), out)
})
When("the no certificate is set in the configuration", Label("remote-https-bad-cert"), func() {
BeforeEach(func() {
expectedInstallationSuccess = false
AfterEach(func() {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
})
})
When("the no certificate is set in the configuration", Label("remote-https-bad-cert"), func() {
var tpmHash string
BeforeEach(func() {
tpmHash = createTPMPassphraseSecret(vm)
expectedInstallationSuccess = false
config = fmt.Sprintf(`#cloud-config
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
@@ -392,19 +356,13 @@ kcrypt:
challenger:
challenger_server: "https://%s"
`, os.Getenv("KMS_ADDRESS"))
})
})
It("fails to talk to the server", func() {
out, err := vm.Sudo("cat manual-install.txt")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("failed to verify certificate: x509: certificate signed by unknown authority"))
})
AfterEach(func() {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
It("fails to talk to the server", func() {
out, err := vm.Sudo("cat manual-install.txt")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("failed to verify certificate: x509: certificate signed by unknown authority"))
})
})
})
})
@@ -416,6 +374,19 @@ func printInstallationOutput(message string, callerSkip ...int) {
Fail(message, callerSkip[0]+1)
}
func kubectlApplyYaml(yamlData string) {
yamlFile, err := os.CreateTemp("", "")
Expect(err).ToNot(HaveOccurred())
defer os.Remove(yamlFile.Name())
err = os.WriteFile(yamlFile.Name(), []byte(yamlData), 0744)
Expect(err).ToNot(HaveOccurred())
cmd := exec.Command("kubectl", "apply", "-f", yamlFile.Name())
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), out)
}
func getChallengerServerCert() string {
cmd := exec.Command(
"kubectl", "get", "secret", "-n", "default", "kms-tls",
@@ -437,10 +408,6 @@ func createConfigWithCert(server, cert string) client.Config {
func createTPMPassphraseSecret(vm VM) string {
tpmHash, err := vm.Sudo("/system/discovery/kcrypt-discovery-challenger")
Expect(err).ToNot(HaveOccurred(), tpmHash)
tpmHash = strings.TrimSpace(tpmHash)
// Use safe Kubernetes name (TPM hash is 64 chars, exceeds 63 char limit)
sealedVolumeName := getSealedVolumeName(tpmHash)
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
@@ -449,11 +416,11 @@ metadata:
name: "%[1]s"
namespace: default
spec:
TPMHash: "%[2]s"
TPMHash: "%[1]s"
partitions:
- label: COS_PERSISTENT
quarantined: false
`, sealedVolumeName, tpmHash))
`, strings.TrimSpace(tpmHash)))
return tpmHash
}

View File

@@ -1,258 +0,0 @@
package e2e_test
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/spectrocloud/peg/matcher"
)
// Advanced scenarios that test complex operational workflows,
// performance aspects, and edge cases
var _ = Describe("Remote Attestation E2E Tests", Label("remote-complete-workflow"), func() {
var config string
var vmOpts VMOptions
var expectedInstallationSuccess bool
var testVM VM
var tpmHash string
BeforeEach(func() {
expectedInstallationSuccess = true
vmOpts = DefaultVMOptions()
_, testVM = startVM(vmOpts)
testVM.EventuallyConnects(1200)
})
AfterEach(func() {
cleanupVM(testVM)
// Clean up test resources if tpmHash was set
if tpmHash != "" {
cleanupTestResources(tpmHash)
}
})
installKairosWithConfig := func(config string) {
installKairosWithConfigAdvanced(testVM, config, expectedInstallationSuccess)
}
It("should perform TOFU enrollment, quarantine testing, PCR management, AK management, error handling, secret reuse, and multi-partition support", func() {
tpmHash = getTPMHash(testVM)
deleteSealedVolume(tpmHash)
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
- COS_PERSISTENT
- COS_OEM
grub_options:
extra_cmdline: "rd.neednet=1"
reboot: false
kcrypt:
challenger:
challenger_server: "http://%s"
`, os.Getenv("KMS_ADDRESS"))
installKairosWithConfig(config)
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
// Verify both partitions are encrypted
By("Verifying both partitions are encrypted")
out, err := testVM.Sudo("blkid")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("TYPE=\"crypto_LUKS\" PARTLABEL=\"persistent\""), out)
Expect(out).To(MatchRegexp("TYPE=\"crypto_LUKS\" PARTLABEL=\"oem\""), out)
By("Verifying SealedVolume was auto-created with attestation data")
Eventually(func() bool {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
// Check that attestation data was populated (not empty)
return strings.Contains(string(out), "attestation:") &&
strings.Contains(string(out), "ekPublicKey:") &&
strings.Contains(string(out), "akPublicKey:")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying encryption secrets were auto-generated for both partitions")
Eventually(func() bool {
sealedVolumeName := getSealedVolumeName(tpmHash)
return secretExists(fmt.Sprintf("%s-cos-persistent", sealedVolumeName)) &&
secretExists(fmt.Sprintf("%s-cos-oem", sealedVolumeName))
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Testing subsequent authentication with learned attestation data")
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("quarantining the TPM")
quarantineTPM(tpmHash)
By("Testing that quarantined TPM is rejected via CLI for both partitions")
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", false)
expectPassphraseRetrieval(testVM, "COS_OEM", false)
By("Testing recovery by unquarantining TPM")
unquarantineTPM(tpmHash)
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", true)
expectPassphraseRetrieval(testVM, "COS_OEM", true)
// Continue with PCR and AK Management testing
By("Testing PCR re-enrollment by setting PCR 0 to wrong value")
updateSealedVolumeAttestation(tpmHash, "pcrValues.pcrs.0", "wrong-pcr0-value")
By("checking that the passphrase retrieval fails with wrong PCR for both partitions")
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", false)
expectPassphraseRetrieval(testVM, "COS_OEM", false)
By("setting PCR 0 to an empty value (re-enrollment mode)")
updateSealedVolumeAttestation(tpmHash, "pcrValues.pcrs.0", "")
By("checking that the passphrase retrieval works after PCR re-enrollment for both partitions")
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", true)
expectPassphraseRetrieval(testVM, "COS_OEM", true)
By("Verifying PCR 0 was re-enrolled with current value")
Eventually(func() bool {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
// PCR 0 should now have a new non-empty value
return strings.Contains(string(out), "\"0\":") &&
!strings.Contains(string(out), "\"0\": \"\"") &&
!strings.Contains(string(out), "\"0\": \"wrong-pcr0-value\"")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
// Continue with EK Management testing (transient AK approach)
By("Testing EK re-enrollment by setting EK to empty")
updateSealedVolumeAttestation(tpmHash, "ekPublicKey", "")
By("Verifying EK was re-enrolled with actual value")
var learnedEK string
Eventually(func() bool {
sealedVolumeName := getSealedVolumeName(tpmHash)
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
// Extract learned EK for later enforcement test
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, "ekPublicKey:") && !strings.Contains(line, "ekPublicKey: \"\"") {
parts := strings.Split(line, "ekPublicKey:")
if len(parts) > 1 {
learnedEK = strings.TrimSpace(strings.Trim(parts[1], "\""))
}
}
}
return learnedEK != ""
}, 30*time.Second, 5*time.Second).Should(BeTrue())
// Test EK enforcement by setting wrong EK
By("Testing EK enforcement by setting wrong EK value")
updateSealedVolumeAttestation(tpmHash, "ekPublicKey", "wrong-ek-value")
time.Sleep(5 * time.Second)
// Should fail to retrieve passphrase with wrong EK for both partitions
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", false)
expectPassphraseRetrieval(testVM, "COS_OEM", false)
// Restore correct EK and verify it works via CLI
By("Restoring correct EK and verifying authentication works for both partitions")
updateSealedVolumeAttestation(tpmHash, "ekPublicKey", learnedEK)
time.Sleep(5 * time.Second)
// Should now work with correct EK for both partitions
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", true)
expectPassphraseRetrieval(testVM, "COS_OEM", true)
// Continue with Error Handling testing
By("Testing invalid TPM hash rejection")
invalidHash := "invalid-tpm-hash-12345"
createSealedVolumeWithAttestation(invalidHash, nil)
// Should fail due to TPM hash mismatch for both partitions (test via CLI, no risky reboot)
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", false)
expectPassphraseRetrieval(testVM, "COS_OEM", false)
// Cleanup invalid SealedVolume
deleteSealedVolume(invalidHash)
// Test with correct TPM hash to verify system still works for both partitions
By("Verifying system still works with correct TPM hash for both partitions")
// The original SealedVolume should still exist and work
expectPassphraseRetrieval(testVM, "COS_PERSISTENT", true)
expectPassphraseRetrieval(testVM, "COS_OEM", true)
// Continue with Secret Reuse testing
By("Testing secret reuse when SealedVolume is recreated for both partitions")
sealedVolumeName := getSealedVolumeName(tpmHash)
persistentSecretName := fmt.Sprintf("%s-cos-persistent", sealedVolumeName)
oemSecretName := fmt.Sprintf("%s-cos-oem", sealedVolumeName)
// Get secret data for comparison for both partitions
cmd := exec.Command("kubectl", "get", "secret", persistentSecretName, "-o", "yaml")
originalPersistentSecretData, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
cmd = exec.Command("kubectl", "get", "secret", oemSecretName, "-o", "yaml")
originalOemSecretData, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
// Delete SealedVolume but keep secrets
deleteSealedVolume(tpmHash)
// Verify secrets still exist
Expect(secretExists(persistentSecretName)).To(BeTrue())
Expect(secretExists(oemSecretName)).To(BeTrue())
// Recreate SealedVolume and verify secret reuse
By("Recreating SealedVolume and verifying secret reuse for both partitions")
createSealedVolumeWithAttestation(tpmHash, nil)
// Should reuse existing secrets
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
// Verify the same secrets are being used
cmd = exec.Command("kubectl", "get", "secret", persistentSecretName, "-o", "yaml")
newPersistentSecretData, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
cmd = exec.Command("kubectl", "get", "secret", oemSecretName, "-o", "yaml")
newOemSecretData, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
// The secret data should be identical (reused, not regenerated) for both partitions
Expect(string(newPersistentSecretData)).To(Equal(string(originalPersistentSecretData)))
Expect(string(newOemSecretData)).To(Equal(string(originalOemSecretData)))
})
})

View File

@@ -1,525 +0,0 @@
package e2e_test
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/spectrocloud/peg/matcher"
)
// Selective Enrollment E2E Tests
// These tests verify the selective enrollment policy for TPM attestation:
// - Empty string ("") = re-enrollment mode (learn on first use, enforce thereafter)
// - Set value = enforcement mode (require exact match)
// - Omitted (nil/not in map) = skip entirely (never verify, never store)
var _ = Describe("Selective Enrollment E2E Tests", func() {
Describe("EK-Only Verification (Empty Attestation Object)", Label("remote-ek-only"), func() {
var config string
var vmOpts VMOptions
var testVM VM
var tpmHash string
BeforeEach(func() {
vmOpts = DefaultVMOptions()
_, testVM = startVM(vmOpts)
testVM.EventuallyConnects(1200)
tpmHash = getTPMHash(testVM)
})
AfterEach(func() {
cleanupVM(testVM)
if tpmHash != "" {
cleanupTestResources(tpmHash)
}
})
It("should handle empty attestation object (EK-only verification, no PCRs)", func() {
By("Creating SealedVolume with empty attestation object")
sealedVolumeName := getSealedVolumeName(tpmHash)
// Create Secret with known passphrase
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: v1
kind: Secret
metadata:
name: %s-cos-persistent
namespace: default
type: Opaque
stringData:
passphrase: "test-passphrase-for-ek-only"
`, sealedVolumeName))
// Create SealedVolume with empty attestation
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: "%s"
namespace: default
spec:
TPMHash: "%s"
partitions:
- label: COS_PERSISTENT
secret:
name: %s-cos-persistent
path: passphrase
attestation: {} # Empty - should learn EK, skip all PCRs
`, sealedVolumeName, tpmHash, sealedVolumeName))
By("Installing Kairos with encryption")
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
- COS_PERSISTENT
grub_options:
extra_cmdline: "rd.neednet=1"
reboot: false
kcrypt:
challenger:
challenger_server: "http://%s"
`, os.Getenv("KMS_ADDRESS"))
installKairosWithConfigAdvanced(testVM, config, true)
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Verifying EK was learned and stored")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
// EK should be present and not empty
return strings.Contains(outStr, "ekPublicKey:") &&
!strings.Contains(outStr, "ekPublicKey: \"\"") &&
!strings.Contains(outStr, "ekPublicKey: |")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying NO PCRs were stored")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
// PCRValues should either not exist or be null/empty
return !strings.Contains(outStr, "pcrValues:") ||
strings.Contains(outStr, "pcrValues: null") ||
strings.Contains(outStr, "pcrValues: {}")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying subsequent boot works with EK enforcement but no PCR checks")
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Testing that CLI passphrase retrieval works")
success := checkPassphraseRetrieval(testVM, "COS_PERSISTENT")
Expect(success).To(BeTrue(), "Passphrase retrieval should succeed with EK-only verification")
})
})
Describe("Selective PCR Tracking from Initial Setup", Label("remote-selective-pcr"), func() {
var config string
var vmOpts VMOptions
var testVM VM
var tpmHash string
BeforeEach(func() {
vmOpts = DefaultVMOptions()
_, testVM = startVM(vmOpts)
testVM.EventuallyConnects(1200)
tpmHash = getTPMHash(testVM)
})
AfterEach(func() {
cleanupVM(testVM)
if tpmHash != "" {
cleanupTestResources(tpmHash)
}
})
It("should handle selective PCR tracking from initial setup (track PCR 0,7 only, skip PCR 11)", func() {
By("Creating SealedVolume with selective PCR configuration")
sealedVolumeName := getSealedVolumeName(tpmHash)
// Create Secret with known passphrase
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: v1
kind: Secret
metadata:
name: %s-cos-persistent
namespace: default
type: Opaque
stringData:
passphrase: "test-passphrase-selective-pcr"
`, sealedVolumeName))
// Create SealedVolume with selective PCRs (0 and 7 only, skip 11)
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: "%s"
namespace: default
spec:
TPMHash: "%s"
partitions:
- label: COS_PERSISTENT
secret:
name: %s-cos-persistent
path: passphrase
attestation:
ekPublicKey: "" # Re-enrollment mode (learn EK)
pcrValues:
pcrs:
"0": "" # Re-enrollment mode (learn PCR 0)
"7": "" # Re-enrollment mode (learn PCR 7)
# "11" omitted - should be skipped entirely
`, sealedVolumeName, tpmHash, sealedVolumeName))
By("Installing Kairos with encryption")
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
- COS_PERSISTENT
grub_options:
extra_cmdline: "rd.neednet=1"
reboot: false
kcrypt:
challenger:
challenger_server: "http://%s"
`, os.Getenv("KMS_ADDRESS"))
installKairosWithConfigAdvanced(testVM, config, true)
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Verifying only PCRs 0 and 7 were learned (not 11)")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
hasPCR0 := strings.Contains(outStr, "\"0\":")
hasPCR7 := strings.Contains(outStr, "\"7\":")
noPCR11 := !strings.Contains(outStr, "\"11\":")
// Verify PCR 0 and 7 have non-empty values
notEmptyPCR0 := !strings.Contains(outStr, "\"0\": \"\"")
notEmptyPCR7 := !strings.Contains(outStr, "\"7\": \"\"")
return hasPCR0 && hasPCR7 && noPCR11 && notEmptyPCR0 && notEmptyPCR7
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying EK was also learned")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
return strings.Contains(outStr, "ekPublicKey:") &&
!strings.Contains(outStr, "ekPublicKey: \"\"")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying subsequent boot works with PCR 0,7 enforcement but PCR 11 ignored")
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Testing that CLI passphrase retrieval works")
success := checkPassphraseRetrieval(testVM, "COS_PERSISTENT")
Expect(success).To(BeTrue(), "Passphrase retrieval should succeed with selective PCR tracking")
})
})
Describe("EK Re-enrollment Mode", Label("remote-ek-reenroll"), func() {
var config string
var vmOpts VMOptions
var testVM VM
var tpmHash string
BeforeEach(func() {
vmOpts = DefaultVMOptions()
_, testVM = startVM(vmOpts)
testVM.EventuallyConnects(1200)
tpmHash = getTPMHash(testVM)
})
AfterEach(func() {
cleanupVM(testVM)
if tpmHash != "" {
cleanupTestResources(tpmHash)
}
})
It("should learn EK when set to empty string (re-enrollment mode)", func() {
By("Performing initial TOFU enrollment")
deleteSealedVolume(tpmHash)
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
- COS_PERSISTENT
grub_options:
extra_cmdline: "rd.neednet=1"
reboot: false
kcrypt:
challenger:
challenger_server: "http://%s"
`, os.Getenv("KMS_ADDRESS"))
installKairosWithConfigAdvanced(testVM, config, true)
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Verifying initial EK was learned")
sealedVolumeName := getSealedVolumeName(tpmHash)
var learnedEK string
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
// Extract the EK value
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, "ekPublicKey:") && !strings.Contains(line, "ekPublicKey: \"\"") {
parts := strings.Split(line, "ekPublicKey:")
if len(parts) > 1 {
learnedEK = strings.TrimSpace(strings.Trim(parts[1], "\""))
}
}
}
return learnedEK != ""
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Setting EK to empty string (re-enrollment mode)")
updateSealedVolumeAttestation(tpmHash, "ekPublicKey", "")
By("Verifying EK re-enrolls on next boot")
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
// EK should now be populated (re-learned)
return strings.Contains(outStr, "ekPublicKey:") &&
!strings.Contains(outStr, "ekPublicKey: \"\"")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying the EK value is the same as before (same TPM)")
var reEnrolledEK string
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, "ekPublicKey:") && !strings.Contains(line, "ekPublicKey: \"\"") {
parts := strings.Split(line, "ekPublicKey:")
if len(parts) > 1 {
reEnrolledEK = strings.TrimSpace(strings.Trim(parts[1], "\""))
}
}
}
return reEnrolledEK != ""
}, 30*time.Second, 5*time.Second).Should(BeTrue())
// The EK should be the same (same TPM)
Expect(reEnrolledEK).To(Equal(learnedEK), "Re-enrolled EK should match original EK (same TPM)")
})
})
Describe("Mixed Attestation Modes", Label("remote-mixed-modes"), func() {
var config string
var vmOpts VMOptions
var testVM VM
var tpmHash string
BeforeEach(func() {
vmOpts = DefaultVMOptions()
_, testVM = startVM(vmOpts)
testVM.EventuallyConnects(1200)
tpmHash = getTPMHash(testVM)
})
AfterEach(func() {
cleanupVM(testVM)
if tpmHash != "" {
cleanupTestResources(tpmHash)
}
})
It("should handle mixed modes: EK enforcement + PCR re-enrollment + PCR omission", func() {
By("Performing initial TOFU enrollment to learn EK and PCRs")
deleteSealedVolume(tpmHash)
config = fmt.Sprintf(`#cloud-config
hostname: metal-{{ trunc 4 .MachineID }}
users:
- name: kairos
passwd: kairos
groups:
- admin
install:
encrypted_partitions:
- COS_PERSISTENT
grub_options:
extra_cmdline: "rd.neednet=1"
reboot: false
kcrypt:
challenger:
challenger_server: "http://%s"
`, os.Getenv("KMS_ADDRESS"))
installKairosWithConfigAdvanced(testVM, config, true)
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Getting the learned EK and PCR values")
sealedVolumeName := getSealedVolumeName(tpmHash)
var learnedEK, learnedPCR0, learnedPCR7 string
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
lines := strings.Split(outStr, "\n")
for i, line := range lines {
if strings.Contains(line, "ekPublicKey:") {
// Get EK (might be multiline)
if i+1 < len(lines) {
learnedEK = strings.TrimSpace(lines[i+1])
}
}
if strings.Contains(line, "\"0\":") {
parts := strings.Split(line, "\"0\":")
if len(parts) > 1 {
learnedPCR0 = strings.TrimSpace(strings.Trim(parts[1], "\""))
}
}
if strings.Contains(line, "\"7\":") {
parts := strings.Split(line, "\"7\":")
if len(parts) > 1 {
learnedPCR7 = strings.TrimSpace(strings.Trim(parts[1], "\""))
}
}
}
return learnedEK != "" && learnedPCR0 != "" && learnedPCR7 != ""
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Reconfiguring with mixed modes: EK enforced, PCR 0 re-enrollment, PCR 7 enforced, PCR 11 omitted")
// Get the full SealedVolume and update it
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred())
// Parse and modify the SealedVolume
// Set PCR 0 to empty (re-enrollment mode)
// Keep PCR 7 as is (enforcement mode)
// Remove PCR 11 if it exists (omit mode)
patch := fmt.Sprintf(`{"spec":{"attestation":{"pcrValues":{"pcrs":{"0":"","7":"%s"}}}}}`, learnedPCR7)
cmd = exec.Command("kubectl", "patch", "sealedvolume", sealedVolumeName, "--type=merge", "-p", patch)
out, err = cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
By("Rebooting and verifying mixed mode works")
rebootAndConnect(testVM)
verifyEncryptedPartition(testVM)
By("Verifying PCR 0 was re-enrolled (learned new value)")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
// PCR 0 should have a value (not empty)
return strings.Contains(outStr, "\"0\":") && !strings.Contains(outStr, "\"0\": \"\"")
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying PCR 7 remained in enforcement mode (same value)")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
return strings.Contains(outStr, fmt.Sprintf("\"7\": \"%s\"", learnedPCR7))
}, 30*time.Second, 5*time.Second).Should(BeTrue())
By("Verifying EK remained in enforcement mode")
Eventually(func() bool {
cmd := exec.Command("kubectl", "get", "sealedvolume", sealedVolumeName, "-o", "yaml")
out, err := cmd.CombinedOutput()
if err != nil {
return false
}
outStr := string(out)
// EK should still be present and match
return strings.Contains(outStr, "ekPublicKey:") && strings.Contains(outStr, learnedEK)
}, 30*time.Second, 5*time.Second).Should(BeTrue())
})
})
})

View File

@@ -8,8 +8,6 @@ import (
"os/exec"
"path"
"strconv"
"strings"
"syscall"
"testing"
"github.com/google/uuid"
@@ -22,11 +20,8 @@ import (
"github.com/spectrocloud/peg/pkg/machine/types"
)
// Global VM variable for fail handler access
var globalVM *VM
func TestE2e(t *testing.T) {
RegisterFailHandler(printChallengerLogsOnFailure)
RegisterFailHandler(Fail)
RunSpecs(t, "kcrypt-challenger e2e test Suite")
}
@@ -191,7 +186,6 @@ func startVM(vmOpts VMOptions) (context.Context, VM) {
Expect(err).ToNot(HaveOccurred())
vm := NewVM(m, stateDir)
globalVM = &vm // Set global VM for fail handler access
ctx, err := vm.Start(context.Background())
Expect(err).ToNot(HaveOccurred())
@@ -238,407 +232,3 @@ func getFreePort() (port int, err error) {
}
return
}
// ========================================
// Common Test Helper Functions
// ========================================
// Helper to install Kairos with given config
func installKairosWithConfig(vm VM, config string) {
configFile, err := os.CreateTemp("", "")
Expect(err).ToNot(HaveOccurred())
defer os.Remove(configFile.Name())
err = os.WriteFile(configFile.Name(), []byte(config), 0744)
Expect(err).ToNot(HaveOccurred())
err = vm.Scp(configFile.Name(), "config.yaml", "0744")
Expect(err).ToNot(HaveOccurred())
By("Installing Kairos with config")
installationOutput, err := vm.Sudo("/bin/bash -c 'set -o pipefail && kairos-agent manual-install --device auto config.yaml 2>&1 | tee manual-install.txt'")
Expect(err).ToNot(HaveOccurred(), installationOutput)
}
// Helper to reboot and wait for connection
func rebootAndConnect(vm VM) {
By("Rebooting VM")
vm.Reboot()
By("Waiting for VM to be connectable")
vm.EventuallyConnects(1200)
}
// Helper to verify encrypted partition exists
func verifyEncryptedPartition(vm VM) {
By("Verifying encrypted partition exists")
out, err := vm.Sudo("blkid")
Expect(err).ToNot(HaveOccurred(), out)
Expect(out).To(MatchRegexp("TYPE=\"crypto_LUKS\" PARTLABEL=\"persistent\""), out)
Expect(out).To(MatchRegexp("/dev/mapper.*LABEL=\"COS_PERSISTENT\""), out)
}
// Helper to get TPM hash from VM
func getTPMHash(vm VM) string {
By("Getting TPM hash from VM")
hash, err := vm.Sudo("/system/discovery/kcrypt-discovery-challenger")
Expect(err).ToNot(HaveOccurred(), hash)
return strings.TrimSpace(hash)
}
// Helper to test passphrase retrieval via CLI (returns true if successful, false if failed)
func checkPassphraseRetrieval(vm VM, partitionLabel string) bool {
By(fmt.Sprintf("Testing passphrase retrieval for partition %s via CLI", partitionLabel))
// Configure the CLI to use the challenger server
cliCmd := fmt.Sprintf(`/system/discovery/kcrypt-discovery-challenger get \
--partition-label=%s \
--challenger-server="http://%s" \
2>/dev/null`, partitionLabel, os.Getenv("KMS_ADDRESS"))
out, err := vm.Sudo(cliCmd)
if err != nil {
By(fmt.Sprintf("Passphrase retrieval failed: %v", err))
return false
}
// Check if we got a passphrase (non-empty output)
passphrase := strings.TrimSpace(out)
success := len(passphrase) > 0
if success {
By("Passphrase retrieval successful")
} else {
By("Passphrase retrieval failed - empty response")
}
return success
}
// Helper to test passphrase retrieval with expectation (for cleaner test logic)
func expectPassphraseRetrieval(vm VM, partitionLabel string, shouldSucceed bool) {
success := checkPassphraseRetrieval(vm, partitionLabel)
if shouldSucceed {
Expect(success).To(BeTrue(), "Passphrase retrieval should have succeeded")
} else {
Expect(success).To(BeFalse(), "Passphrase retrieval should have failed")
}
}
// Helper to get the correct SealedVolume name from TPM hash
func getSealedVolumeName(tpmHash string) string {
// Convert to lowercase and take first 8 characters to match the actual naming pattern
// This matches the pattern used in pkg/challenger/challenger.go: fmt.Sprintf("tofu-%s", tpmHash[:8])
return fmt.Sprintf("tofu-%s", strings.ToLower(tpmHash[:8]))
}
// Helper to create SealedVolume with specific attestation configuration
func createSealedVolumeWithAttestation(tpmHash string, attestationConfig map[string]interface{}) {
sealedVolumeName := getSealedVolumeName(tpmHash)
sealedVolumeYaml := fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: "%s"
namespace: default
spec:
TPMHash: "%s"
partitions:
- label: COS_PERSISTENT
quarantined: false`, sealedVolumeName, tpmHash)
if attestationConfig != nil {
sealedVolumeYaml += "\n attestation:"
for key, value := range attestationConfig {
switch v := value.(type) {
case string:
sealedVolumeYaml += fmt.Sprintf("\n %s: \"%s\"", key, v)
case map[string]string:
sealedVolumeYaml += fmt.Sprintf("\n %s:", key)
for k, val := range v {
sealedVolumeYaml += "\n pcrs:"
sealedVolumeYaml += fmt.Sprintf("\n \"%s\": \"%s\"", k, val)
}
}
}
}
By(fmt.Sprintf("Creating SealedVolume with attestation config: %+v", attestationConfig))
kubectlApplyYaml(sealedVolumeYaml)
}
// Helper to update SealedVolume attestation configuration
func updateSealedVolumeAttestation(tpmHashParam string, field, value string) {
sealedVolumeName := getSealedVolumeName(tpmHashParam)
By(fmt.Sprintf("Updating SealedVolume %s field %s to %s", sealedVolumeName, field, value))
patch := fmt.Sprintf(`{"spec":{"attestation":{"%s":"%s"}}}`, field, value)
cmd := exec.Command("kubectl", "patch", "sealedvolume", sealedVolumeName, "--type=merge", "-p", patch)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to quarantine TPM
func quarantineTPM(tpmHash string) {
sealedVolumeName := getSealedVolumeName(tpmHash)
By(fmt.Sprintf("Quarantining TPM %s", sealedVolumeName))
patch := `{"spec":{"quarantined":true}}`
cmd := exec.Command("kubectl", "patch", "sealedvolume", sealedVolumeName, "--type=merge", "-p", patch)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to unquarantine TPM
func unquarantineTPM(tpmHashParam string) {
sealedVolumeName := getSealedVolumeName(tpmHashParam)
By(fmt.Sprintf("Unquarantining TPM %s", sealedVolumeName))
patch := `{"spec":{"quarantined":false}}`
cmd := exec.Command("kubectl", "patch", "sealedvolume", sealedVolumeName, "--type=merge", "-p", patch)
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to delete SealedVolume
func deleteSealedVolume(tpmHashParam string) {
sealedVolumeName := getSealedVolumeName(tpmHashParam)
By(fmt.Sprintf("Deleting SealedVolume %s", sealedVolumeName))
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName, "--ignore-not-found=true")
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to delete SealedVolume from all namespaces
func deleteSealedVolumeAllNamespaces(tpmHashParam string) {
sealedVolumeName := getSealedVolumeName(tpmHashParam)
By(fmt.Sprintf("Deleting SealedVolume %s from all namespaces", sealedVolumeName))
cmd := exec.Command("kubectl", "delete", "sealedvolume", sealedVolumeName, "--ignore-not-found=true", "--all-namespaces")
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to check if secret exists
func secretExists(secretName string) bool {
cmd := exec.Command("kubectl", "get", "secret", secretName, "--ignore-not-found=true")
out, err := cmd.CombinedOutput()
return err == nil && len(out) > 0 && !strings.Contains(string(out), "NotFound")
}
// Helper to check if secret exists in namespace
func secretExistsInNamespace(secretName, namespace string) bool {
cmd := exec.Command("kubectl", "get", "secret", secretName, "-n", namespace, "--ignore-not-found=true")
out, err := cmd.CombinedOutput()
return err == nil && len(out) > 0 && !strings.Contains(string(out), "NotFound")
}
// Helper to apply YAML to Kubernetes
func kubectlApplyYaml(yamlData string) {
yamlFile, err := os.CreateTemp("", "")
Expect(err).ToNot(HaveOccurred())
defer os.Remove(yamlFile.Name())
err = os.WriteFile(yamlFile.Name(), []byte(yamlData), 0744)
Expect(err).ToNot(HaveOccurred())
cmd := exec.Command("kubectl", "apply", "-f", yamlFile.Name())
out, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), string(out))
}
// Helper to create SealedVolume with multi-partition configuration
func createMultiPartitionSealedVolume(tpmHash string, partitions []string) {
sealedVolumeYaml := fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: "%s"
namespace: default
spec:
TPMHash: "%s"
partitions:`, tpmHash, tpmHash)
for _, partition := range partitions {
sealedVolumeYaml += fmt.Sprintf(`
- label: %s`, partition)
}
sealedVolumeYaml += "\n quarantined: false"
By(fmt.Sprintf("Creating multi-partition SealedVolume for partitions: %v", partitions))
kubectlApplyYaml(sealedVolumeYaml)
}
// Helper to create SealedVolume in specific namespace
func createSealedVolumeInNamespace(tpmHash, namespace string) {
// First create the namespace if it doesn't exist with test labels
kubectlApplyYaml(fmt.Sprintf(`---
apiVersion: v1
kind: Namespace
metadata:
name: %s
labels:
test.kcrypt.kairos.io/type: test-namespace
test.kcrypt.kairos.io/purpose: kcrypt-challenger-testing`, namespace))
sealedVolumeYaml := fmt.Sprintf(`---
apiVersion: keyserver.kairos.io/v1alpha1
kind: SealedVolume
metadata:
name: "%s"
namespace: %s
spec:
TPMHash: "%s"
partitions:
- label: COS_PERSISTENT
quarantined: false`, tpmHash, namespace, tpmHash)
By(fmt.Sprintf("Creating SealedVolume in namespace %s", namespace))
kubectlApplyYaml(sealedVolumeYaml)
}
// Helper to cleanup test resources
func cleanupTestResources(tpmHash string) {
if tpmHash != "" {
deleteSealedVolumeAllNamespaces(tpmHash)
// Cleanup associated secrets using labels
// This will delete all secrets created by kcrypt-challenger for this TPM hash
cmd := exec.Command("kubectl", "delete", "secret",
"-l", fmt.Sprintf("kcrypt.kairos.io/tpm-hash=%s", tpmHash),
"--ignore-not-found=true", "--all-namespaces")
cmd.CombinedOutput()
}
}
// Helper to delete specific test namespaces
func deleteTestNamespaces(namespaces ...string) {
for _, namespace := range namespaces {
cmd := exec.Command("kubectl", "delete", "namespace", namespace, "--ignore-not-found=true")
cmd.CombinedOutput()
}
}
// Helper to install Kairos with config (handles both success and failure cases)
func installKairosWithConfigAdvanced(vm VM, config string, expectSuccess bool) {
configFile, err := os.CreateTemp("", "")
Expect(err).ToNot(HaveOccurred())
defer os.Remove(configFile.Name())
err = os.WriteFile(configFile.Name(), []byte(config), 0744)
Expect(err).ToNot(HaveOccurred())
err = vm.Scp(configFile.Name(), "config.yaml", "0744")
Expect(err).ToNot(HaveOccurred())
if expectSuccess {
By("Installing Kairos with config")
installationOutput, err := vm.Sudo("/bin/bash -c 'set -o pipefail && kairos-agent manual-install --device auto config.yaml 2>&1 | tee manual-install.txt'")
Expect(err).ToNot(HaveOccurred(), installationOutput)
} else {
By("Installing Kairos with config (expecting failure)")
vm.Sudo("/bin/bash -c 'set -o pipefail && kairos-agent manual-install --device auto config.yaml 2>&1 | tee manual-install.txt'")
}
}
// Helper to cleanup VM and TPM emulator
func cleanupVM(vm VM) {
By("Cleaning up test VM")
err := vm.Destroy(func(vm VM) {
// Stop TPM emulator
tpmPID, err := os.ReadFile(path.Join(vm.StateDir, "tpm", "pid"))
if err == nil && len(tpmPID) != 0 {
pid, err := strconv.Atoi(string(tpmPID))
if err == nil {
syscall.Kill(pid, syscall.SIGKILL)
}
}
})
Expect(err).ToNot(HaveOccurred())
}
// Fail handler that captures challenger logs when any test fails
func printChallengerLogsOnFailure(message string, callerSkip ...int) {
if globalVM != nil {
fmt.Printf("\n=== TEST FAILED - CAPTURING CHALLENGER LOGS ===\n")
// Try to read the challenger log file
logOutput, err := globalVM.Sudo("cat /var/log/kairos/kcrypt-discovery-challenger.log 2>/dev/null || echo 'Log file not found'")
if err != nil {
logOutput = fmt.Sprintf("Error reading challenger log: %v", err)
}
// Get additional system information that might be helpful
processInfo, err := globalVM.Sudo("ps aux | grep kcrypt-discovery-challenger || echo 'No challenger processes found'")
if err != nil {
processInfo = fmt.Sprintf("Error getting process info: %v", err)
}
// Check if the challenger binary exists and is executable
binaryInfo, err := globalVM.Sudo("ls -la /system/discovery/kcrypt-discovery-challenger 2>/dev/null || echo 'Challenger binary not found'")
if err != nil {
binaryInfo = fmt.Sprintf("Error checking binary: %v", err)
}
// Check TPM status
tpmInfo, err := globalVM.Sudo("ls -la /dev/tpm* 2>/dev/null || echo 'No TPM devices found'")
if err != nil {
tpmInfo = fmt.Sprintf("Error checking TPM: %v", err)
}
// Print the logs to help with debugging
fmt.Printf("Challenger log file content:\n%s\n", logOutput)
fmt.Printf("\nProcess information:\n%s\n", processInfo)
fmt.Printf("\nBinary information:\n%s\n", binaryInfo)
fmt.Printf("\nTPM device information:\n%s\n", tpmInfo)
fmt.Printf("=== END CHALLENGER LOGS ===\n\n")
} else {
fmt.Printf("\n=== TEST FAILED - NO VM AVAILABLE FOR LOG CAPTURE ===\n")
}
// Capture kcrypt-challenger-server logs from Kubernetes
fmt.Printf("\n=== CAPTURING KCRYPT-CHALLENGER-SERVER LOGS ===\n")
// First, let's see what namespaces and pods exist
allPods, err := exec.Command("kubectl", "get", "pods", "-A").Output()
if err != nil {
allPods = []byte(fmt.Sprintf("Error getting all pods: %v", err))
}
fmt.Printf("All pods in cluster:\n%s\n", string(allPods))
// Try to get server logs from both possible namespaces
// Check system namespace first (based on challenger-patch.yaml)
serverLogs, err := exec.Command("kubectl", "logs", "-n", "system", "-l", "control-plane=controller-manager", "--tail=500").Output()
if err != nil {
serverLogs = []byte(fmt.Sprintf("Error getting server logs from system namespace: %v", err))
}
fmt.Printf("Server logs from system namespace (last 500 lines):\n%s\n", string(serverLogs))
// Also check default namespace (based on kustomization override)
serverLogsDefault, err := exec.Command("kubectl", "logs", "-n", "default", "-l", "control-plane=controller-manager", "--tail=500").Output()
if err != nil {
serverLogsDefault = []byte(fmt.Sprintf("Error getting server logs from default namespace: %v", err))
}
fmt.Printf("Server logs from default namespace (last 500 lines):\n%s\n", string(serverLogsDefault))
// Get logs from the last 10 minutes from both namespaces
serverLogsAll, err := exec.Command("kubectl", "logs", "-n", "system", "-l", "control-plane=controller-manager", "--since=10m").Output()
if err != nil {
serverLogsAll = []byte(fmt.Sprintf("Error getting recent server logs from system namespace: %v", err))
}
fmt.Printf("\nServer logs from system namespace (last 10 minutes):\n%s\n", string(serverLogsAll))
serverLogsAllDefault, err := exec.Command("kubectl", "logs", "-n", "default", "-l", "control-plane=controller-manager", "--since=10m").Output()
if err != nil {
serverLogsAllDefault = []byte(fmt.Sprintf("Error getting recent server logs from default namespace: %v", err))
}
fmt.Printf("\nServer logs from default namespace (last 10 minutes):\n%s\n", string(serverLogsAllDefault))
// Check if there are any sealedvolume resources that might be relevant
sealedVolumeInfo, err := exec.Command("kubectl", "get", "sealedvolume", "-A", "-o", "wide").Output()
if err != nil {
sealedVolumeInfo = []byte(fmt.Sprintf("Error getting sealedvolume info: %v", err))
}
fmt.Printf("\nSealedVolume resources:\n%s\n", string(sealedVolumeInfo))
fmt.Printf("=== END KCRYPT-CHALLENGER-SERVER LOGS ===\n\n")
// Ensures the correct line numbers are reported
Fail(message, callerSkip[0]+1)
}