ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
2 changed files with 145336 additions and 0 deletions
445
packages/oci-l4-relay-publisher/cmd/publish/main.go
Normal file
445
packages/oci-l4-relay-publisher/cmd/publish/main.go
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maximumBundleBytes = 64 << 20
|
||||
|
||||
var objectPrefixPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*/v[1-9][0-9]*$`)
|
||||
|
||||
type credentials struct {
|
||||
tenancy string
|
||||
user string
|
||||
fingerprint string
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
type stringValues []string
|
||||
|
||||
func (values *stringValues) String() string {
|
||||
return strings.Join(*values, ",")
|
||||
}
|
||||
|
||||
func (values *stringValues) Set(value string) error {
|
||||
*values = append(*values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type publication struct {
|
||||
Prefix string `json:"prefix"`
|
||||
VersionObject string `json:"version_object"`
|
||||
VersionCreated bool `json:"version_created"`
|
||||
CurrentObject string `json:"current_object"`
|
||||
CurrentPromoted bool `json:"current_promoted"`
|
||||
}
|
||||
|
||||
type smtpDenySummary struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DeniedIntervalCount int `json:"denied_interval_count"`
|
||||
DeniedAddressCount uint64 `json:"denied_address_count"`
|
||||
RemovedIntervalCount int `json:"removed_interval_count"`
|
||||
}
|
||||
|
||||
func readSMTPDenySummary(path string) (*smtpDenySummary, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
summary := smtpDenySummary{}
|
||||
if err := decoder.Decode(&summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return nil, errors.New("SMTP deny summary contains trailing data")
|
||||
}
|
||||
if summary.GeneratedAt.IsZero() ||
|
||||
!summary.ExpiresAt.After(summary.GeneratedAt) ||
|
||||
summary.DeniedIntervalCount < 0 ||
|
||||
summary.RemovedIntervalCount < 0 {
|
||||
return nil, errors.New("SMTP deny summary is invalid")
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func publicationPrefixes(primary string, additional []string) ([]string, error) {
|
||||
values := append([]string{primary}, additional...)
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if !objectPrefixPattern.MatchString(value) {
|
||||
return nil, fmt.Errorf("invalid object prefix %q", value)
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
return nil, fmt.Errorf("duplicate object prefix %q", value)
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func publicationObjects(prefix, digest string) (string, string) {
|
||||
return prefix + "/versions/" + digest + ".json.gz", prefix + "/current.json.gz"
|
||||
}
|
||||
|
||||
func publishBundle(ctx context.Context, client *http.Client, endpoint, namespace, bucket string, prefixes []string, body []byte, identity credentials, promoteCurrent bool) (string, []publication, error) {
|
||||
digest := sha256.Sum256(body)
|
||||
digestText := hex.EncodeToString(digest[:])
|
||||
publications := make([]publication, 0, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
versionObject, currentObject := publicationObjects(prefix, digestText)
|
||||
created, err := upload(ctx, client, endpoint, namespace, bucket, versionObject, body, identity, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := verifyObject(ctx, client, endpoint, namespace, bucket, versionObject, body, identity); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if promoteCurrent {
|
||||
if _, err := upload(ctx, client, endpoint, namespace, bucket, currentObject, body, identity, false); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := verifyObject(ctx, client, endpoint, namespace, bucket, currentObject, body, identity); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
publications = append(publications, publication{
|
||||
Prefix: prefix,
|
||||
VersionObject: versionObject,
|
||||
VersionCreated: created,
|
||||
CurrentObject: currentObject,
|
||||
CurrentPromoted: promoteCurrent,
|
||||
})
|
||||
}
|
||||
return digestText, publications, nil
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
contents, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(contents, '\n'), 0o644)
|
||||
}
|
||||
|
||||
func readCredential(directory, name string) (string, error) {
|
||||
contents, err := os.ReadFile(filepath.Join(directory, name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read OCI %s", strings.ReplaceAll(name, "_", " "))
|
||||
}
|
||||
value := strings.TrimSpace(string(contents))
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("OCI %s is empty", strings.ReplaceAll(name, "_", " "))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func optionalCredential(directory, name, fallback string) string {
|
||||
value, err := readCredential(directory, name)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parsePrivateKey(value string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(value))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode OCI private key")
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse OCI private key")
|
||||
}
|
||||
key, ok := parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("OCI private key is not RSA")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func readCredentials(directory string) (credentials, error) {
|
||||
tenancy, err := readCredential(directory, "tenancy_ocid")
|
||||
if err != nil {
|
||||
return credentials{}, err
|
||||
}
|
||||
user, err := readCredential(directory, "user_ocid")
|
||||
if err != nil {
|
||||
return credentials{}, err
|
||||
}
|
||||
fingerprint, err := readCredential(directory, "fingerprint")
|
||||
if err != nil {
|
||||
return credentials{}, err
|
||||
}
|
||||
privateKeyPEM, err := readCredential(directory, "private_key")
|
||||
if err != nil {
|
||||
return credentials{}, err
|
||||
}
|
||||
privateKey, err := parsePrivateKey(privateKeyPEM)
|
||||
if err != nil {
|
||||
return credentials{}, err
|
||||
}
|
||||
return credentials{tenancy: tenancy, user: user, fingerprint: fingerprint, privateKey: privateKey}, nil
|
||||
}
|
||||
|
||||
func objectURL(endpoint, namespace, bucket, object string) (string, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Scheme != "https" || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("invalid OCI Object Storage endpoint")
|
||||
}
|
||||
parsed.Path = "/n/" + namespace + "/b/" + bucket + "/o/" + object
|
||||
parsed.RawPath = "/n/" + url.PathEscape(namespace) + "/b/" + url.PathEscape(bucket) + "/o/" + url.PathEscape(object)
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func signRequest(request *http.Request, body []byte, identity credentials, now time.Time) error {
|
||||
digest := sha256.Sum256(body)
|
||||
request.Header.Set("Date", now.UTC().Format(http.TimeFormat))
|
||||
request.Header.Set("Content-Type", "application/gzip")
|
||||
request.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
request.Header.Set("X-Content-SHA256", base64.StdEncoding.EncodeToString(digest[:]))
|
||||
request.ContentLength = int64(len(body))
|
||||
headerNames := "(request-target) host date x-content-sha256 content-type content-length"
|
||||
signingString := strings.Join([]string{
|
||||
"(request-target): " + strings.ToLower(request.Method) + " " + request.URL.EscapedPath(),
|
||||
"host: " + request.URL.Host,
|
||||
"date: " + request.Header.Get("Date"),
|
||||
"x-content-sha256: " + request.Header.Get("X-Content-SHA256"),
|
||||
"content-type: " + request.Header.Get("Content-Type"),
|
||||
"content-length: " + request.Header.Get("Content-Length"),
|
||||
}, "\n")
|
||||
signingDigest := sha256.Sum256([]byte(signingString))
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, identity.privateKey, crypto.SHA256, signingDigest[:])
|
||||
if err != nil {
|
||||
return errors.New("sign OCI request")
|
||||
}
|
||||
keyID := identity.tenancy + "/" + identity.user + "/" + identity.fingerprint
|
||||
request.Header.Set("Authorization", fmt.Sprintf(`Signature version="1",keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`, keyID, headerNames, base64.StdEncoding.EncodeToString(signature)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func signDownloadRequest(request *http.Request, identity credentials, now time.Time) error {
|
||||
request.Header.Set("Date", now.UTC().Format(http.TimeFormat))
|
||||
headerNames := "(request-target) host date"
|
||||
signingString := strings.Join([]string{
|
||||
"(request-target): " + strings.ToLower(request.Method) + " " + request.URL.EscapedPath(),
|
||||
"host: " + request.URL.Host,
|
||||
"date: " + request.Header.Get("Date"),
|
||||
}, "\n")
|
||||
signingDigest := sha256.Sum256([]byte(signingString))
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, identity.privateKey, crypto.SHA256, signingDigest[:])
|
||||
if err != nil {
|
||||
return errors.New("sign OCI request")
|
||||
}
|
||||
keyID := identity.tenancy + "/" + identity.user + "/" + identity.fingerprint
|
||||
request.Header.Set("Authorization", fmt.Sprintf(`Signature version="1",keyId="%s",algorithm="rsa-sha256",headers="%s",signature="%s"`, keyID, headerNames, base64.StdEncoding.EncodeToString(signature)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func upload(ctx context.Context, client *http.Client, endpoint, namespace, bucket, object string, body []byte, identity credentials, immutable bool) (bool, error) {
|
||||
target, err := objectURL(endpoint, namespace, bucket, object)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPut, target, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, errors.New("create OCI upload request")
|
||||
}
|
||||
if immutable {
|
||||
request.Header.Set("If-None-Match", "*")
|
||||
}
|
||||
if err := signRequest(request, body, identity, time.Now()); err != nil {
|
||||
return false, err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false, errors.New("upload OCI object")
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
if immutable && response.StatusCode == http.StatusPreconditionFailed {
|
||||
return false, nil
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return false, fmt.Errorf("upload OCI object: HTTP %d", response.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func download(ctx context.Context, client *http.Client, endpoint, namespace, bucket, object string, identity credentials) ([]byte, error) {
|
||||
target, err := objectURL(endpoint, namespace, bucket, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("create OCI download request")
|
||||
}
|
||||
if err := signDownloadRequest(request, identity, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("download OCI object")
|
||||
}
|
||||
defer func() { _ = response.Body.Close() }()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
|
||||
return nil, fmt.Errorf("download OCI object: HTTP %d", response.StatusCode)
|
||||
}
|
||||
contents, err := io.ReadAll(io.LimitReader(response.Body, maximumBundleBytes+1))
|
||||
if err != nil {
|
||||
return nil, errors.New("read OCI object")
|
||||
}
|
||||
if len(contents) > maximumBundleBytes {
|
||||
return nil, errors.New("OCI object exceeds maximum bundle size")
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func verifyObject(ctx context.Context, client *http.Client, endpoint, namespace, bucket, object string, expected []byte, identity credentials) error {
|
||||
contents, err := download(ctx, client, endpoint, namespace, bucket, object, identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedDigest := sha256.Sum256(expected)
|
||||
actualDigest := sha256.Sum256(contents)
|
||||
if actualDigest != expectedDigest {
|
||||
return fmt.Errorf("verify OCI object %q: SHA-256 mismatch", object)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
bundlePath := flag.String("bundle", "/work/output/current.json.gz", "validated admission bundle")
|
||||
credentialsDirectory := flag.String("credentials-directory", "/credentials/oci", "OCI credential directory")
|
||||
region := flag.String("region", "", "OCI region")
|
||||
namespace := flag.String("namespace", "", "OCI Object Storage namespace")
|
||||
bucket := flag.String("bucket", "", "OCI Object Storage bucket")
|
||||
objectPrefix := flag.String("object-prefix", "admission/v1", "Object Storage prefix for versions and current")
|
||||
additionalObjectPrefixes := stringValues{}
|
||||
flag.Var(&additionalObjectPrefixes, "additional-object-prefix", "additional Object Storage prefix for the same bundle")
|
||||
promoteCurrent := flag.Bool("promote-current", true, "promote the validated bundle after immutable upload")
|
||||
endpoint := flag.String("endpoint", "", "OCI Object Storage endpoint")
|
||||
resultFile := flag.String("result-file", "", "write sanitized publication result")
|
||||
smtpDenySummaryPath := flag.String("smtp-deny-summary", "", "validated SMTP deny summary")
|
||||
restoreVersionSHA256 := flag.String("restore-version-sha256", "", "download an immutable version by SHA-256")
|
||||
restoreOutput := flag.String("restore-output", "/work/output/restore.json.gz", "write the immutable version for validation")
|
||||
flag.Parse()
|
||||
*region = optionalCredential(*credentialsDirectory, "region", *region)
|
||||
*namespace = optionalCredential(*credentialsDirectory, "object_storage_namespace", *namespace)
|
||||
*bucket = optionalCredential(*credentialsDirectory, "bucket_name", *bucket)
|
||||
if *region == "" || *namespace == "" || *bucket == "" {
|
||||
fmt.Fprintln(os.Stderr, "region, namespace, and bucket are required")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *endpoint == "" {
|
||||
*endpoint = "https://objectstorage." + *region + ".oraclecloud.com"
|
||||
}
|
||||
prefixes, err := publicationPrefixes(*objectPrefix, additionalObjectPrefixes)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
smtpSummary, err := readSMTPDenySummary(*smtpDenySummaryPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
identity, err := readCredentials(*credentialsDirectory)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
client := &http.Client{Timeout: 2 * time.Minute}
|
||||
if *restoreVersionSHA256 != "" {
|
||||
if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(*restoreVersionSHA256) {
|
||||
fmt.Fprintln(os.Stderr, "restore version SHA-256 must be 64 lowercase hexadecimal characters")
|
||||
os.Exit(2)
|
||||
}
|
||||
versionObject, _ := publicationObjects(prefixes[0], *restoreVersionSHA256)
|
||||
bundle, err := download(ctx, client, *endpoint, *namespace, *bucket, versionObject, identity)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
digest := sha256.Sum256(bundle)
|
||||
if hex.EncodeToString(digest[:]) != *restoreVersionSHA256 {
|
||||
fmt.Fprintln(os.Stderr, "downloaded immutable version does not match requested SHA-256")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(*restoreOutput), 0o700); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "create restore output directory")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*restoreOutput, bundle, 0o600); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write restored immutable version")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("restored_bundle_sha256=%s\n", *restoreVersionSHA256)
|
||||
return
|
||||
}
|
||||
bundle, err := os.ReadFile(*bundlePath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "read validated admission bundle")
|
||||
os.Exit(1)
|
||||
}
|
||||
digestText, publications, err := publishBundle(ctx, client, *endpoint, *namespace, *bucket, prefixes, bundle, identity, *promoteCurrent)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *resultFile != "" {
|
||||
result := struct {
|
||||
BundleSHA256 string `json:"bundle_sha256"`
|
||||
Publications []publication `json:"publications"`
|
||||
SMTPDenySummary *smtpDenySummary `json:"smtp_deny_summary,omitempty"`
|
||||
}{BundleSHA256: digestText, Publications: publications, SMTPDenySummary: smtpSummary}
|
||||
if err := writeJSONFile(*resultFile, result); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
fmt.Printf("bundle_sha256=%s publication_count=%d current_promoted=%t\n", digestText, len(publications), *promoteCurrent)
|
||||
}
|
||||
358
packages/oci-l4-relay-publisher/cmd/publish/main_test.go
Normal file
358
packages/oci-l4-relay-publisher/cmd/publish/main_test.go
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteJSONFileCreatesParentDirectory(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow", "publication-result.json")
|
||||
if err := writeJSONFile(path, map[string]bool{"published": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != "{\"published\":true}\n" {
|
||||
t.Fatalf("unexpected contents %q", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectURL(t *testing.T) {
|
||||
result, err := objectURL("https://objectstorage.example.invalid", "namespace", "bucket", "admission/v1/current.json.gz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != "https://objectstorage.example.invalid/n/namespace/b/bucket/o/admission%2Fv1%2Fcurrent.json.gz" {
|
||||
t.Fatalf("unexpected object URL %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicationPrefixes(t *testing.T) {
|
||||
prefixes, err := publicationPrefixes("admission/v1", []string{"web-allow/v1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(prefixes, ",") != "admission/v1,web-allow/v1" {
|
||||
t.Fatalf("unexpected prefixes %q", prefixes)
|
||||
}
|
||||
version, current := publicationObjects(prefixes[1], strings.Repeat("a", 64))
|
||||
if version != "web-allow/v1/versions/"+strings.Repeat("a", 64)+".json.gz" {
|
||||
t.Fatalf("unexpected version object %q", version)
|
||||
}
|
||||
if current != "web-allow/v1/current.json.gz" {
|
||||
t.Fatalf("unexpected current object %q", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicationPrefixesRejectInvalidOrDuplicateValues(t *testing.T) {
|
||||
for name, values := range map[string][]string{
|
||||
"empty": {""},
|
||||
"traversal": {"../web-allow/v1"},
|
||||
"nested": {"mail/smtp-deny/v1"},
|
||||
"duplicate": {"web-allow/v1", "web-allow/v1"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := publicationPrefixes(values[0], values[1:]); err == nil {
|
||||
t.Fatal("expected prefix validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBundleUsesIdenticalContentForBothPrefixes(t *testing.T) {
|
||||
type uploadRequest struct {
|
||||
body string
|
||||
immutable bool
|
||||
escapedPath string
|
||||
}
|
||||
requests := make([]uploadRequest, 0, 4)
|
||||
objects := map[string][]byte{}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodGet {
|
||||
body, exists := objects[request.URL.EscapedPath()]
|
||||
if !exists {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(body)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
objects[request.URL.EscapedPath()] = body
|
||||
requests = append(requests, uploadRequest{
|
||||
body: string(body),
|
||||
immutable: request.Header.Get("If-None-Match") == "*",
|
||||
escapedPath: request.URL.EscapedPath(),
|
||||
})
|
||||
response.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte("validated bundle")
|
||||
digest, publications, err := publishBundle(
|
||||
context.Background(),
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"namespace",
|
||||
"bucket",
|
||||
[]string{"admission/v1", "web-allow/v1"},
|
||||
body,
|
||||
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(publications) != 2 || len(requests) != 4 {
|
||||
t.Fatalf("publications=%d requests=%d", len(publications), len(requests))
|
||||
}
|
||||
expectedPaths := []string{
|
||||
"/n/namespace/b/bucket/o/admission%2Fv1%2Fversions%2F" + digest + ".json.gz",
|
||||
"/n/namespace/b/bucket/o/admission%2Fv1%2Fcurrent.json.gz",
|
||||
"/n/namespace/b/bucket/o/web-allow%2Fv1%2Fversions%2F" + digest + ".json.gz",
|
||||
"/n/namespace/b/bucket/o/web-allow%2Fv1%2Fcurrent.json.gz",
|
||||
}
|
||||
for index, request := range requests {
|
||||
if request.body != string(body) {
|
||||
t.Errorf("request %d changed bundle content", index)
|
||||
}
|
||||
if request.escapedPath != expectedPaths[index] {
|
||||
t.Errorf("request %d path %q", index, request.escapedPath)
|
||||
}
|
||||
if request.immutable != (index%2 == 0) {
|
||||
t.Errorf("request %d immutable=%t", index, request.immutable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBundleVerifiesExistingImmutableObject(t *testing.T) {
|
||||
body := []byte("validated bundle")
|
||||
digest := sha256.Sum256(body)
|
||||
versionPath := "/n/namespace/b/bucket/o/smtp-deny%2Fv1%2Fversions%2F" + fmt.Sprintf("%x", digest) + ".json.gz"
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPut {
|
||||
response.WriteHeader(http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
if request.URL.EscapedPath() != versionPath {
|
||||
t.Errorf("unexpected path %q", request.URL.EscapedPath())
|
||||
}
|
||||
_, _ = response.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, publications, err := publishBundle(
|
||||
context.Background(),
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"namespace",
|
||||
"bucket",
|
||||
[]string{"smtp-deny/v1"},
|
||||
body,
|
||||
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(publications) != 1 || publications[0].VersionCreated {
|
||||
t.Fatalf("unexpected publications %#v", publications)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBundleRejectsMismatchedExistingImmutableObject(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPut {
|
||||
response.WriteHeader(http.StatusPreconditionFailed)
|
||||
return
|
||||
}
|
||||
_, _ = response.Write([]byte("different bundle"))
|
||||
}))
|
||||
defer server.Close()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = publishBundle(
|
||||
context.Background(),
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"namespace",
|
||||
"bucket",
|
||||
[]string{"smtp-deny/v1"},
|
||||
[]byte("validated bundle"),
|
||||
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
||||
false,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBundleRejectsMismatchedCurrentObject(t *testing.T) {
|
||||
objects := map[string][]byte{}
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
path := request.URL.EscapedPath()
|
||||
if request.Method == http.MethodPut {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
objects[path] = body
|
||||
response.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(path, "current.json.gz") {
|
||||
_, _ = response.Write([]byte("different bundle"))
|
||||
return
|
||||
}
|
||||
_, _ = response.Write(objects[path])
|
||||
}))
|
||||
defer server.Close()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = publishBundle(
|
||||
context.Background(),
|
||||
server.Client(),
|
||||
server.URL,
|
||||
"namespace",
|
||||
"bucket",
|
||||
[]string{"smtp-deny/v1"},
|
||||
[]byte("validated bundle"),
|
||||
credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key},
|
||||
true,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "current.json.gz") || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
||||
t.Fatalf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSMTPDenySummary(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.json")
|
||||
contents := `{"generated_at":"2026-07-26T00:00:00Z","expires_at":"2026-07-27T00:00:00Z","denied_interval_count":2,"denied_address_count":17,"removed_interval_count":1}`
|
||||
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary, err := readSMTPDenySummary(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.DeniedIntervalCount != 2 || summary.DeniedAddressCount != 17 || summary.RemovedIntervalCount != 1 {
|
||||
t.Fatalf("unexpected summary %#v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSMTPDenySummaryRejectsContentEvidence(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "summary.json")
|
||||
contents := `{"generated_at":"2026-07-26T00:00:00Z","expires_at":"2026-07-27T00:00:00Z","denied_interval_count":1,"denied_address_count":1,"removed_interval_count":0,"recipient":"[email protected]"}`
|
||||
if err := os.WriteFile(path, []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readSMTPDenySummary(path); err == nil {
|
||||
t.Fatal("expected content-bearing summary rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignRequest(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte("bundle")
|
||||
request, err := http.NewRequest(http.MethodPut, "https://objectstorage.example.invalid/n/ns/b/b/o/current.json.gz", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key}
|
||||
if err := signRequest(request, body, identity, time.Unix(0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorization := request.Header.Get("Authorization")
|
||||
marker := `signature="`
|
||||
start := strings.Index(authorization, marker)
|
||||
if start < 0 {
|
||||
t.Fatal("missing signature")
|
||||
}
|
||||
encoded := strings.TrimSuffix(authorization[start+len(marker):], `"`)
|
||||
signature, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signingString := strings.Join([]string{
|
||||
"(request-target): put /n/ns/b/b/o/current.json.gz",
|
||||
"host: objectstorage.example.invalid",
|
||||
"date: " + request.Header.Get("Date"),
|
||||
"x-content-sha256: " + request.Header.Get("X-Content-SHA256"),
|
||||
"content-type: application/gzip",
|
||||
"content-length: 6",
|
||||
}, "\n")
|
||||
digest := sha256.Sum256([]byte(signingString))
|
||||
if err := rsa.VerifyPKCS1v15(&key.PublicKey, 0, digest[:], signature); err == nil {
|
||||
t.Fatal("signature unexpectedly verified without SHA-256")
|
||||
}
|
||||
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignDownloadRequest(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodGet, "https://objectstorage.example.invalid/n/ns/b/b/o/admission%2Fv1%2Fversions%2Fbundle.json.gz", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := credentials{tenancy: "tenancy", user: "user", fingerprint: "fingerprint", privateKey: key}
|
||||
if err := signDownloadRequest(request, identity, time.Unix(0, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorization := request.Header.Get("Authorization")
|
||||
marker := `signature="`
|
||||
start := strings.Index(authorization, marker)
|
||||
if start < 0 {
|
||||
t.Fatal("missing signature")
|
||||
}
|
||||
encoded := strings.TrimSuffix(authorization[start+len(marker):], `"`)
|
||||
signature, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signingString := strings.Join([]string{
|
||||
"(request-target): get /n/ns/b/b/o/admission%2Fv1%2Fversions%2Fbundle.json.gz",
|
||||
"host: objectstorage.example.invalid",
|
||||
"date: " + request.Header.Get("Date"),
|
||||
}, "\n")
|
||||
digest := sha256.Sum256([]byte(signingString))
|
||||
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue