Notary is a critical component in the container security ecosystem that enables trust and integrity verification for container images. It implements The Update Framework (TUF) to securely distribute and verify content, preventing tampering and man-in-the-middle attacks.
Understanding Notary in the Container Trust Ecosystem
Notary provides:
- Digital Signing: Cryptographically sign container images
- Integrity Verification: Ensure images haven't been tampered with
- Trust Delegation: Manage signing responsibilities across teams
- Revocation Support: Invalidate compromised signatures
- TUF Compliance: Implements The Update Framework standard
Key Components
- Notary Server: Stores and serves signed image metadata
- Notary Signer: Handles private key operations
- Notary Database: Stores trust metadata
- Docker Content Trust: Client-side trust verification
- TUF Metadata: Root, targets, snapshots, and timestamp roles
Java Dependencies Setup
Add these dependencies to your pom.xml:
<dependencies> <!-- HTTP client for Notary API communication --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <!-- JSON processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.16.1</version> </dependency> <!-- Cryptography libraries --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcpkix-jdk18on</artifactId> <version>1.76</version> </dependency> <!-- Docker registry client --> <dependency> <groupId>com.github.docker-java</groupId> <artifactId>docker-java</artifactId> <version>3.3.4</version> </dependency> </dependencies>
Notary Client Implementation
Here's a comprehensive Java client for interacting with Notary:
package com.example.notary;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import java.io.*;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class NotaryClient {
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final String notaryServerUrl;
private final String notarySignerUrl;
public NotaryClient(String notaryServerUrl, String notarySignerUrl) {
this.httpClient = new OkHttpClient();
this.objectMapper = new ObjectMapper();
this.notaryServerUrl = notaryServerUrl;
this.notarySignerUrl = notarySignerUrl;
}
public static class ImageReference {
private final String registry;
private final String repository;
private final String tag;
private final String digest;
public ImageReference(String registry, String repository, String tag, String digest) {
this.registry = registry;
this.repository = repository;
this.tag = tag;
this.digest = digest;
}
public String getGUN() {
return registry + "/" + repository;
}
// Getters
public String getRegistry() { return registry; }
public String getRepository() { return repository; }
public String getTag() { return tag; }
public String getDigest() { return digest; }
}
public static class SigningResult {
private boolean success;
private String signature;
private String certificate;
private String error;
// Constructors, getters, and setters
public SigningResult(boolean success, String signature, String certificate) {
this.success = success;
this.signature = signature;
this.certificate = certificate;
}
public SigningResult(String error) {
this.success = false;
this.error = error;
}
public boolean isSuccess() { return success; }
public String getSignature() { return signature; }
public String getCertificate() { return certificate; }
public String getError() { return error; }
}
public static class VerificationResult {
private boolean trusted;
private String digest;
private Map<String, String> signatures;
private String error;
public VerificationResult(boolean trusted, String digest, Map<String, String> signatures) {
this.trusted = trusted;
this.digest = digest;
this.signatures = signatures;
}
public VerificationResult(String error) {
this.trusted = false;
this.error = error;
}
// Getters
public boolean isTrusted() { return trusted; }
public String getDigest() { return digest; }
public Map<String, String> getSignatures() { return signatures; }
public String getError() { return error; }
}
/**
* Sign a container image
*/
public SigningResult signImage(ImageReference image, PrivateKey privateKey,
String keyId) throws IOException {
try {
// Prepare signing request
Map<String, Object> signRequest = new HashMap<>();
signRequest.put("gun", image.getGUN());
signRequest.put("tag", image.getTag());
signRequest.put("digest", image.getDigest());
// Convert request to JSON
String jsonRequest = objectMapper.writeValueAsString(signRequest);
// Create HTTP request to Notary signer
Request request = new Request.Builder()
.url(notarySignerUrl + "/sign")
.post(RequestBody.create(jsonRequest,
MediaType.parse("application/json")))
.header("X-Notary-Key-Id", keyId)
.build();
// Execute request
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
return new SigningResult("Signing failed: " + response.message());
}
String responseBody = response.body().string();
Map<String, Object> signResponse = objectMapper.readValue(
responseBody, Map.class);
String signature = (String) signResponse.get("signature");
String certificate = (String) signResponse.get("certificate");
return new SigningResult(true, signature, certificate);
}
} catch (Exception e) {
return new SigningResult("Signing error: " + e.getMessage());
}
}
/**
* Verify image trust
*/
public VerificationResult verifyImage(ImageReference image) throws IOException {
try {
// Get trust data from Notary server
String gun = image.getGUN();
Request request = new Request.Builder()
.url(notaryServerUrl + "/v2/" + gun + "/_trust/tuf/targets.json")
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
return new VerificationResult("Verification failed: " + response.message());
}
String responseBody = response.body().string();
Map<String, Object> trustData = objectMapper.readValue(responseBody, Map.class);
// Extract targets metadata
Map<String, Object> signed = (Map<String, Object>) trustData.get("signed");
Map<String, Object> targets = (Map<String, Object>) signed.get("targets");
// Check if our image tag exists in targets
Map<String, Object> target = (Map<String, Object>) targets.get(image.getTag());
if (target == null) {
return new VerificationResult("Image tag not found in trust data");
}
// Verify digest matches
String trustedDigest = (String) target.get("hashes").get("sha256");
if (!trustedDigest.equals(image.getDigest())) {
return new VerificationResult("Digest mismatch: expected " +
trustedDigest + " but got " + image.getDigest());
}
// Extract signatures
Map<String, String> signatures = extractSignatures(trustData);
return new VerificationResult(true, trustedDigest, signatures);
}
} catch (Exception e) {
return new VerificationResult("Verification error: " + e.getMessage());
}
}
private Map<String, String> extractSignatures(Map<String, Object> trustData) {
Map<String, String> signatures = new HashMap<>();
@SuppressWarnings("unchecked")
Map<String, Object> signed = (Map<String, Object>) trustData.get("signed");
@SuppressWarnings("unchecked")
Map<String, Object> target = (Map<String, Object>) signed.get("targets");
if (target != null) {
target.forEach((key, value) -> {
if (value instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> targetInfo = (Map<String, Object>) value;
if (targetInfo.containsKey("custom")) {
@SuppressWarnings("unchecked")
Map<String, Object> custom = (Map<String, Object>) targetInfo.get("custom");
if (custom != null && custom.containsKey("signer")) {
signatures.put(key, (String) custom.get("signer"));
}
}
}
});
}
return signatures;
}
/**
* Add signed image to Notary server
*/
public boolean publishSignature(ImageReference image, SigningResult signingResult)
throws IOException {
try {
Map<String, Object> publishRequest = new HashMap<>();
publishRequest.put("gun", image.getGUN());
publishRequest.put("tag", image.getTag());
publishRequest.put("digest", image.getDigest());
publishRequest.put("signature", signingResult.getSignature());
publishRequest.put("certificate", signingResult.getCertificate());
String jsonRequest = objectMapper.writeValueAsString(publishRequest);
Request request = new Request.Builder()
.url(notaryServerUrl + "/v2/" + image.getGUN() + "/_trust/tuf/")
.post(RequestBody.create(jsonRequest,
MediaType.parse("application/json")))
.build();
try (Response response = httpClient.newCall(request).execute()) {
return response.isSuccessful();
}
} catch (Exception e) {
throw new IOException("Failed to publish signature: " + e.getMessage(), e);
}
}
}
Image Trust Manager for Container Workflows
Here's a higher-level service that manages image trust operations:
package com.example.notary;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class ImageTrustManager {
private final NotaryClient notaryClient;
private final Map<String, Set<String>> trustedRegistries;
private final Set<String> trustedSigners;
public ImageTrustManager(NotaryClient notaryClient) {
this.notaryClient = notaryClient;
this.trustedRegistries = new ConcurrentHashMap<>();
this.trustedSigners = ConcurrentHashMap.newKeySet();
// Initialize with default trusted registries
initializeTrustedRegistries();
}
private void initializeTrustedRegistries() {
trustedRegistries.put("docker.io", Set.of("library", "verified"));
trustedRegistries.put("gcr.io", Set.of("google-containers"));
trustedRegistries.put("registry.k8s.io", Set.of(""));
}
public class ImageVerificationPolicy {
private boolean requireSignature;
private boolean enforceDigestMatch;
private Set<String> allowedSigners;
private Set<String> requiredLabels;
public ImageVerificationPolicy(boolean requireSignature, boolean enforceDigestMatch) {
this.requireSignature = requireSignature;
this.enforceDigestMatch = enforceDigestMatch;
this.allowedSigners = new HashSet<>();
this.requiredLabels = new HashSet<>();
}
// Builder methods
public ImageVerificationPolicy withAllowedSigners(Set<String> signers) {
this.allowedSigners = signers;
return this;
}
public ImageVerificationPolicy withRequiredLabels(Set<String> labels) {
this.requiredLabels = labels;
return this;
}
// Getters
public boolean isRequireSignature() { return requireSignature; }
public boolean isEnforceDigestMatch() { return enforceDigestMatch; }
public Set<String> getAllowedSigners() { return allowedSigners; }
public Set<String> getRequiredLabels() { return requiredLabels; }
}
/**
* Verify image against trust policy
*/
public boolean verifyImageAgainstPolicy(NotaryClient.ImageReference image,
ImageVerificationPolicy policy) throws IOException {
// Step 1: Check if registry is trusted
if (!isRegistryTrusted(image.getRegistry(), image.getRepository())) {
throw new SecurityException("Untrusted registry: " + image.getRegistry());
}
// Step 2: Verify image signature and integrity
NotaryClient.VerificationResult verification = notaryClient.verifyImage(image);
if (!verification.isTrusted()) {
if (policy.isRequireSignature()) {
throw new SecurityException("Image not trusted: " + verification.getError());
}
// If signature not required, continue with other checks
}
// Step 3: Check signer against allowed list
if (policy.isRequireSignature() && !policy.getAllowedSigners().isEmpty()) {
boolean hasAllowedSigner = verification.getSignatures().values().stream()
.anyMatch(policy.getAllowedSigners()::contains);
if (!hasAllowedSigner) {
throw new SecurityException("No allowed signer found for image");
}
}
// Step 4: Verify digest match if required
if (policy.isEnforceDigestMatch() && !verification.getDigest().equals(image.getDigest())) {
throw new SecurityException("Image digest does not match trusted digest");
}
return true;
}
private boolean isRegistryTrusted(String registry, String repository) {
Set<String> trustedRepos = trustedRegistries.get(registry);
if (trustedRepos == null) {
return false;
}
// Check if specific repository is trusted, or if wildcard applies
return trustedRepos.contains("") || trustedRepos.contains(repository);
}
/**
* Batch verify multiple images
*/
public Map<String, Boolean> batchVerifyImages(
Map<String, NotaryClient.ImageReference> images,
ImageVerificationPolicy policy) {
Map<String, Boolean> results = new HashMap<>();
images.forEach((imageName, imageRef) -> {
try {
boolean trusted = verifyImageAgainstPolicy(imageRef, policy);
results.put(imageName, trusted);
} catch (Exception e) {
results.put(imageName, false);
}
});
return results;
}
/**
* Add trusted registry
*/
public void addTrustedRegistry(String registry, Set<String> repositories) {
trustedRegistries.put(registry, repositories);
}
/**
* Remove trusted registry
*/
public void removeTrustedRegistry(String registry) {
trustedRegistries.remove(registry);
}
}
Spring Boot Configuration and REST API
package com.example.notary;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@SpringBootApplication
public class NotaryApplication {
public static void main(String[] args) {
SpringApplication.run(NotaryApplication.class, args);
}
@Bean
public NotaryClient notaryClient() {
String notaryServer = System.getenv().getOrDefault(
"NOTARY_SERVER_URL", "https://notary-server:4443");
String notarySigner = System.getenv().getOrDefault(
"NOTARY_SIGNER_URL", "https://notary-signer:4444");
return new NotaryClient(notaryServer, notarySigner);
}
}
@RestController
@RequestMapping("/api/trust")
public class ImageTrustController {
private final ImageTrustManager trustManager;
private final NotaryClient notaryClient;
public ImageTrustController(ImageTrustManager trustManager, NotaryClient notaryClient) {
this.trustManager = trustManager;
this.notaryClient = notaryClient;
}
@PostMapping("/verify")
public VerificationResponse verifyImage(@RequestBody VerifyRequest request) {
try {
NotaryClient.ImageReference imageRef = new NotaryClient.ImageReference(
request.getRegistry(),
request.getRepository(),
request.getTag(),
request.getDigest()
);
ImageTrustManager.ImageVerificationPolicy policy =
new ImageTrustManager.ImageVerificationPolicy(
request.isRequireSignature(),
request.isEnforceDigestMatch()
).withAllowedSigners(request.getAllowedSigners());
boolean isTrusted = trustManager.verifyImageAgainstPolicy(imageRef, policy);
return new VerificationResponse(isTrusted, "Image verification completed");
} catch (Exception e) {
return new VerificationResponse(false, "Verification failed: " + e.getMessage());
}
}
@PostMapping("/sign")
public SigningResponse signImage(@RequestBody SignRequest request) {
try {
NotaryClient.ImageReference imageRef = new NotaryClient.ImageReference(
request.getRegistry(),
request.getRepository(),
request.getTag(),
request.getDigest()
);
// In production, you'd load the private key from secure storage
// PrivateKey privateKey = loadPrivateKey(request.getKeyId());
NotaryClient.SigningResult result = notaryClient.signImage(
imageRef, null, request.getKeyId()); // privateKey would be used here
if (result.isSuccess()) {
// Publish signature to Notary server
notaryClient.publishSignature(imageRef, result);
return new SigningResponse(true, "Image signed successfully",
result.getSignature());
} else {
return new SigningResponse(false, result.getError(), null);
}
} catch (Exception e) {
return new SigningResponse(false, "Signing failed: " + e.getMessage(), null);
}
}
// Request/Response DTOs
public static class VerifyRequest {
private String registry;
private String repository;
private String tag;
private String digest;
private boolean requireSignature;
private boolean enforceDigestMatch;
private java.util.Set<String> allowedSigners;
// Getters and setters
public String getRegistry() { return registry; }
public void setRegistry(String registry) { this.registry = registry; }
public String getRepository() { return repository; }
public void setRepository(String repository) { this.repository = repository; }
public String getTag() { return tag; }
public void setTag(String tag) { this.tag = tag; }
public String getDigest() { return digest; }
public void setDigest(String digest) { this.digest = digest; }
public boolean isRequireSignature() { return requireSignature; }
public void setRequireSignature(boolean requireSignature) { this.requireSignature = requireSignature; }
public boolean isEnforceDigestMatch() { return enforceDigestMatch; }
public void setEnforceDigestMatch(boolean enforceDigestMatch) { this.enforceDigestMatch = enforceDigestMatch; }
public java.util.Set<String> getAllowedSigners() { return allowedSigners; }
public void setAllowedSigners(java.util.Set<String> allowedSigners) { this.allowedSigners = allowedSigners; }
}
public static class VerificationResponse {
private boolean trusted;
private String message;
public VerificationResponse(boolean trusted, String message) {
this.trusted = trusted;
this.message = message;
}
// Getters and setters
public boolean isTrusted() { return trusted; }
public void setTrusted(boolean trusted) { this.trusted = trusted; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
public static class SignRequest {
private String registry;
private String repository;
private String tag;
private String digest;
private String keyId;
// Getters and setters
public String getRegistry() { return registry; }
public void setRegistry(String registry) { this.registry = registry; }
public String getRepository() { return repository; }
public void setRepository(String repository) { this.repository = repository; }
public String getTag() { return tag; }
public void setTag(String tag) { this.tag = tag; }
public String getDigest() { return digest; }
public void setDigest(String digest) { this.digest = digest; }
public String getKeyId() { return keyId; }
public void setKeyId(String keyId) { this.keyId = keyId; }
}
public static class SigningResponse {
private boolean success;
private String message;
private String signature;
public SigningResponse(boolean success, String message, String signature) {
this.success = success;
this.message = message;
this.signature = signature;
}
// Getters and setters
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
}
Best Practices for Production
- Key Management: Store private keys in HSMs or cloud KMS solutions
- Certificate Rotation: Implement automatic certificate rotation
- Audit Logging: Log all signing and verification operations
- Policy as Code: Define trust policies in version-controlled configuration
- CI/CD Integration: Integrate trust verification into your pipeline
- Monitoring: Monitor Notary server health and verification failures
Conclusion
Implementing Notary for image trust in Java provides a robust foundation for securing your container supply chain. By leveraging The Update Framework (TUF) through Notary, you can ensure the integrity and authenticity of your container images throughout their lifecycle.
The Java implementation shown here provides:
- Comprehensive Trust Verification: Validate image signatures and integrity
- Policy-Based Enforcement: Define and enforce organizational trust policies
- RESTful API: Easy integration with existing systems
- Extensible Architecture: Support for multiple registries and signing strategies
This approach helps prevent supply chain attacks, ensures compliance with security policies, and maintains the integrity of your containerized applications from development through production.