diff --git a/connect/docs/DMS2-03-PRUEFPROTOKOLL.md b/connect/docs/DMS2-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..c0df159b --- /dev/null +++ b/connect/docs/DMS2-03-PRUEFPROTOKOLL.md @@ -0,0 +1,103 @@ +# DMS2-03 – Prüfprotokoll: Externe-Backup-Ziel-Anbindung + +## Umsetzung + +Neues Paket `connect/internal/backupconnector`: Connector für externe, +S3-kompatible Backup-Ziele, nach demselben `registry.Connector`-Muster wie +`erpconnector`/`crmconnector`/`m365connector`. Verschlüsselung nutzt die +bestehende Envelope-Encryption aus `connect/internal/crypto` (frischer DEK +je Export, verpackt mit dem Tenant-KEK) statt einer zweiten +Krypto-Implementierung. Das exportierte Format (`Envelope`: gewrappter DEK + +Ciphertext, beide Base64 in JSON) ist offen dokumentiert und ohne +NEXARCH-Werkzeug lesbar, sobald der Tenant-KEK vorliegt – kein +geschlossenes Migrationsformat. + +Retry bei fehlgeschlagenem Export läuft über die bereits bestehende +Fehlerbehandlung aus `connect/internal/syncjob` (ENG-05, +`CompleteRunWithRetry`/`ClaimDueRetries`) – keine zweite, neu erfundene +Retry-Schleife. `backupconnector` liefert dafür nur einen normalen Go-Error +zurück. + +Zugangsdaten (Endpoint, Bucket, Region, AccessKeyID/-SecretKey) werden +ausschließlich über `Config`-Felder hereingereicht, die der Aufrufer aus +Umgebungsvariablen befüllt – keine Zugangsdaten im Code. Sichere Speicherung +dieser Zugangsdaten ist QA-04 (nicht Teil dieser Kachel); dieses Paket hält +lediglich `Config` als Schnittstelle dafür bereit. + +## Prüfungen + +### Prüfung 1 – Verbindungsaufbau zu Test-Backup-Ziel im Test erfolgreich + +Zielwert: `Client.Connect` gegen ein Test-Backup-Ziel meldet Erfolg; +`Client` erfüllt `registry.Connector` und lässt sich in die Registry +einhängen. + +Ergebnis: erfüllt. `TestClient_ImplementsRegistryConnectorAndConnects` +registriert den Connector in einer `registry.Registry` und ruft `Connect` +gegen einen `httptest.Server` (S3-kompatibles Test-Double) auf – Erfolg, +genau ein HEAD-Aufruf mit korrekt geformtem SigV4-`Authorization`-Header. +`TestClient_ConnectFailsOnUnreachableTarget` zeigt zusätzlich den +Fehlerfall (nicht erreichbares Ziel → `ErrConnectionFailed`). + +Da für S3-kompatiblen Speicher keine echten Cloud-Zugangsdaten zur +Verfügung stehen, dient ein `httptest`-Mock als Test-Backup-Ziel (gleiche +Einschränkung wie bei M365-01's OAuth2-Mock-Tests, hier ehrlich +dokumentiert statt die Prüfung auszulassen). Der Mock verifiziert das +Vorhandensein und Format des AWS4-HMAC-SHA256-Headers sowie der +`x-amz-date`/`x-amz-content-sha256`-Header; eine volle +Signatur-Neuberechnung würde eine AWS-SDK-Abhängigkeit voraussetzen, die +Connect bewusst nicht hat. + +### Prüfung 2 – Übertragung im Test nachweislich verschlüsselt + +Zielwert: die auf der Leitung ankommende Nutzlast enthält das +Klartext-Dokument an keiner Stelle; mit dem korrekten Tenant-KEK lässt sie +sich wieder zum Original entschlüsseln, mit einem falschen KEK schlägt die +Entschlüsselung fehl. + +Ergebnis: erfüllt. `TestClient_ExportIsEncryptedInTransit` exportiert einen +(synthetischen, nicht echten) Testdokumenttext, liest die vom Mock-Server +tatsächlich empfangenen Rohbytes, prüft `bytes.Contains` sowohl auf den +rohen Draht-Payload als auch auf den decodierten Ciphertext (in beiden +Fällen: nicht enthalten), entschlüsselt anschließend mit dem korrekten KEK +zurück zum Original (Erfolg) und mit einem falschen KEK (`ErrDecryptFailed` +über `crypto.Service.Open`). + +### Prüfung 3 – Simulierter Übertragungsfehler löst Retry aus + +Zielwert: ein fehlgeschlagener Export führt über die bestehende +Fehlerbehandlung (ENG-05 `syncjob`) zu einem geplanten Retry, der nach +Ablauf der Backoff-Zeit erneut ausgeführt wird und danach erfolgreich +abschließt. + +Ergebnis: erfüllt. `TestExport_TransientFailureTriggersRetryViaSyncjob` +lässt den Mock-Server den ersten Übertragungsversuch mit +503 Service Unavailable ablehnen, ruft `syncjob.Store.CompleteRunWithRetry` +mit dem daraus resultierenden Fehler auf (Status → `retry_pending`), +bestätigt über `ClaimDueRetries`, dass der Retry nach Ablauf der Backoff-Zeit +abholbar ist, wiederholt den Export (jetzt ohne simulierten Ausfall, +erfolgreich) und schließt den Retry-Run über `CompleteRun` erfolgreich ab. +Integrationstest gegen echtes Postgres (`TEST_TENANT_DSN`), eigenes +Wegwerf-Schema pro Testlauf über `testdb.Pool`. + +## Testausführung + +``` +TEST_TENANT_DSN="postgres://postgres:postgres@localhost:5432/dms2_03_backup_test?sslmode=disable" \ + go test ./internal/backupconnector/... -v +``` + +Ausgeführt auf Testhost 192.168.1.204, eigene Wegwerf-Datenbank +`dms2_03_backup_test` (nicht `archivdms`, nicht Host 131). Alle 6 Tests +grün. `go build ./...` und `go vet ./...` für das gesamte `connect`-Modul +ebenfalls ohne Befund. + +## Offene Punkte + +- QA-04 (Sicherheitsprüfung Drittsystem-Credentials) ist bewusst nicht Teil + dieser Kachel; `Config` hält Endpoint/Bucket/Region/AccessKeyID/ + AccessSecretKey als reine Werte bereit, eine sichere Ablage (z. B. über + `credential.Store`) ist dort zu ergänzen. +- Kein echter SigV4-Abgleich gegen eine reale S3-Implementierung (MinIO/AWS) + durchgeführt, da keine echten Zugangsdaten verfügbar sind – nur + Header-Vorhandensein/-Format gegen das Mock-Ziel geprüft. diff --git a/connect/internal/backupconnector/backupconnector.go b/connect/internal/backupconnector/backupconnector.go new file mode 100644 index 00000000..f6f50d6d --- /dev/null +++ b/connect/internal/backupconnector/backupconnector.go @@ -0,0 +1,170 @@ +// Package backupconnector implements the external-backup-target connector +// (DMS2-03): export of document archives to S3-compatible object storage, +// encrypted client-side before transfer. Follows the same registry.Connector +// plugin pattern as erpconnector/crmconnector/m365connector — a backup +// target is one connector under the same interface, not a bolted-on special +// case. Encryption reuses connect/internal/crypto's envelope-encryption +// service (fresh DEK per export, wrapped with the tenant KEK) rather than a +// second crypto implementation. Retry-on-failure (AC3) reuses syncjob's +// existing CompleteRunWithRetry/ClaimDueRetries (ENG-05) instead of a second +// retry loop; this package only has to return a plain error on failure. +// +// The exported envelope is plain JSON (wrapped DEK + ciphertext, both +// base64), documented here and in DMS2-03-PRUEFPROTOKOLL.md, deliberately +// not a closed/proprietary format — any tool that knows the tenant KEK can +// decrypt without NEXARCH-specific tooling. +package backupconnector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/registry" +) + +var ( + // ErrConnectionFailed wraps any failure to reach the backup target. + ErrConnectionFailed = errors.New("backupconnector: connection failed") + // ErrExportFailed wraps any failure of an encrypted export upload. + ErrExportFailed = errors.New("backupconnector: export failed") +) + +// Config configures one external-backup-target connector instance. +// Endpoint, Bucket, Region, AccessKeyID and AccessSecretKey are read from +// environment variables by the caller, never hardcoded (QA-04 covers +// hardening how they are stored; this package only consumes them). +type Config struct { + Name string + Version string + + Endpoint string // e.g. "https://backup.example.com", S3-compatible, path-style + Bucket string + Region string + AccessKeyID string + AccessSecretKey string +} + +// Envelope is the open, documented on-the-wire export format: a fresh +// data-encryption key wrapped with the tenant KEK, plus the ciphertext it +// protects. Readable by any tool that has the tenant KEK, without +// NEXARCH-specific tooling. +type Envelope struct { + WrappedDEK []byte `json:"wrapped_dek_b64"` + Ciphertext []byte `json:"ciphertext_b64"` +} + +// Client is the external-backup-target connector. It implements +// registry.Connector so it can be registered under ENG-01's plugin model. +type Client struct { + cfg Config + crypto *crypto.Service + http *http.Client + now func() time.Time +} + +// New returns a Client for cfg, encrypting exports via cryptoSvc. +func New(cfg Config, cryptoSvc *crypto.Service) *Client { + return &Client{cfg: cfg, crypto: cryptoSvc, http: httpclient.Default(), now: time.Now} +} + +func (c *Client) Metadata() registry.Metadata { + return registry.Metadata{ + Name: c.cfg.Name, + Version: c.cfg.Version, + Operations: []string{"connect", "export"}, + } +} + +// Connect verifies the backup target is reachable and the configured +// credentials are accepted, via a signed HEAD request against the +// configured bucket. +func (c *Client) Connect(ctx context.Context) error { + req, err := c.signedRequest(ctx, http.MethodHead, "/"+c.cfg.Bucket, nil) + if err != nil { + return fmt.Errorf("%w: %w", ErrConnectionFailed, err) + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%w: %w", ErrConnectionFailed, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: status %d: %s", ErrConnectionFailed, resp.StatusCode, string(body)) + } + return nil +} + +// Export encrypts plaintext under a fresh DEK wrapped with the tenant's KEK +// (AC2: the transfer body is always the encrypted Envelope, never +// plaintext), and uploads it to the backup target under objectKey via a +// signed PUT. A returned error is a plain error, not yet retried — the +// caller (a syncjob run) decides via syncjob.Store.CompleteRunWithRetry +// whether and when to retry (AC3), so this package does not duplicate that +// error handling. +func (c *Client) Export(ctx context.Context, tenantSlug, objectKey string, plaintext []byte) error { + sealed, err := c.crypto.Seal(ctx, tenantSlug, plaintext) + if err != nil { + return fmt.Errorf("%w: encrypt: %w", ErrExportFailed, err) + } + envelope := Envelope{ + WrappedDEK: sealed.WrappedDEK, + Ciphertext: sealed.Ciphertext, + } + body, err := json.Marshal(envelope) + if err != nil { + return fmt.Errorf("%w: marshal envelope: %w", ErrExportFailed, err) + } + + path := "/" + c.cfg.Bucket + "/" + strings.TrimPrefix(objectKey, "/") + req, err := c.signedRequest(ctx, http.MethodPut, path, body) + if err != nil { + return fmt.Errorf("%w: %w", ErrExportFailed, err) + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%w: %w", ErrExportFailed, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: status %d: %s", ErrExportFailed, resp.StatusCode, string(respBody)) + } + return nil +} + +// DecodeEnvelope parses the open export format back out of raw object +// bytes, for restore or for any external tool reading the backup target. +func DecodeEnvelope(raw []byte) (Envelope, error) { + var e Envelope + if err := json.Unmarshal(raw, &e); err != nil { + return Envelope{}, fmt.Errorf("backupconnector: decode envelope: %w", err) + } + return e, nil +} + +func (c *Client) signedRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, c.cfg.Endpoint+path, reader) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + req.ContentLength = int64(len(body)) + } + signRequest(req, body, c.cfg.AccessKeyID, c.cfg.AccessSecretKey, c.cfg.Region, "s3", c.now()) + return req, nil +} diff --git a/connect/internal/backupconnector/backupconnector_test.go b/connect/internal/backupconnector/backupconnector_test.go new file mode 100644 index 00000000..363c05de --- /dev/null +++ b/connect/internal/backupconnector/backupconnector_test.go @@ -0,0 +1,289 @@ +package backupconnector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/registry" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/syncjob" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb" +) + +type fakeKEK struct{ key []byte } + +func (f *fakeKEK) TenantKEK(_ context.Context, _ string) ([]byte, error) { return f.key, nil } + +func newTestKEK() *fakeKEK { + key := make([]byte, crypto.KEKSize) + for i := range key { + key[i] = byte(i + 7) + } + return &fakeKEK{key: key} +} + +// mockS3 is a minimal S3-compatible test double: it requires a well-formed +// AWS4-HMAC-SHA256 Authorization header (real SigV4 verification would need +// an AWS SDK we deliberately do not depend on), records the raw bytes it +// received on the wire, and can be told to fail the first N requests to +// simulate a transient outage. No real cloud credentials are used or +// available on this host - this stands in for the real S3-compatible target, +// same limitation as M365-01's OAuth2 mock testing. +type mockS3 struct { + failNext atomic.Int32 + lastBody atomic.Value // []byte + lastAuth atomic.Value // string + callCount atomic.Int32 + forcedCode int +} + +func newMockS3() *mockS3 { return &mockS3{} } + +func (m *mockS3) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + m.callCount.Add(1) + auth := r.Header.Get("Authorization") + m.lastAuth.Store(auth) + if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=") { + t.Errorf("missing/malformed SigV4 Authorization header: %q", auth) + } + if r.Header.Get("x-amz-date") == "" || r.Header.Get("x-amz-content-sha256") == "" { + t.Errorf("missing SigV4 headers") + } + body, _ := io.ReadAll(r.Body) + m.lastBody.Store(body) + + if m.failNext.Load() > 0 { + m.failNext.Add(-1) + code := m.forcedCode + if code == 0 { + code = http.StatusServiceUnavailable + } + w.WriteHeader(code) + return + } + w.WriteHeader(http.StatusOK) + } +} + +func testConfig(endpoint string) Config { + return Config{ + Name: "backup-test", Version: "1.0.0", + Endpoint: endpoint, Bucket: "nexarch-backup", Region: "eu-central-1", + AccessKeyID: "test-key", AccessSecretKey: "test-secret", + } +} + +// AC1 / Prüfung 1: Backup-Ziel richtet sich über das Connector-Plugin-Modell +// ein und der Verbindungsaufbau zum Test-Backup-Ziel gelingt. +func TestClient_ImplementsRegistryConnectorAndConnects(t *testing.T) { + mock := newMockS3() + server := httptest.NewServer(mock.handler(t)) + defer server.Close() + + c := New(testConfig(server.URL), crypto.NewService(newTestKEK())) + + var _ registry.Connector = c + md := c.Metadata() + if md.Name != "backup-test" || md.Version != "1.0.0" { + t.Fatalf("metadata = %+v", md) + } + if len(md.Operations) == 0 { + t.Fatalf("no operations declared") + } + + reg := registry.New() + if err := reg.Register(c); err != nil { + t.Fatalf("register: %v", err) + } + + if err := c.Connect(context.Background()); err != nil { + t.Fatalf("connect to test backup target: %v", err) + } + if mock.callCount.Load() != 1 { + t.Fatalf("call count = %d, want 1", mock.callCount.Load()) + } +} + +func TestClient_ConnectFailsOnUnreachableTarget(t *testing.T) { + c := New(testConfig("http://127.0.0.1:1"), crypto.NewService(newTestKEK())) + if err := c.Connect(context.Background()); err == nil { + t.Fatalf("expected connection error") + } else if !errors.Is(err, ErrConnectionFailed) { + t.Fatalf("err = %v, want ErrConnectionFailed", err) + } +} + +// AC2 / Prüfung 2: Übertragung ist nachweislich verschlüsselt - die auf der +// Leitung ankommende Nutzlast enthält weder das Klartext-Dokument noch +// erlaubt sie dessen Wiederherstellung ohne den Tenant-KEK, und dieselbe +// Nutzlast lässt sich mit dem korrekten KEK wieder zum Original entschlüsseln. +func TestClient_ExportIsEncryptedInTransit(t *testing.T) { + mock := newMockS3() + server := httptest.NewServer(mock.handler(t)) + defer server.Close() + + kek := newTestKEK() + svc := crypto.NewService(kek) + c := New(testConfig(server.URL), svc) + + plaintext := []byte("streng vertrauliches Testdokument, DSGVO-relevant (synthetisch)") + if err := c.Export(context.Background(), "acme", "docs/2026/testdoc.pdf", plaintext); err != nil { + t.Fatalf("export: %v", err) + } + + wire, _ := mock.lastBody.Load().([]byte) + if len(wire) == 0 { + t.Fatalf("no body received by backup target") + } + if bytes.Contains(wire, plaintext) { + t.Fatalf("plaintext found verbatim in wire payload - not encrypted") + } + + env, err := DecodeEnvelope(wire) + if err != nil { + t.Fatalf("decode envelope: %v", err) + } + if bytes.Contains(env.Ciphertext, plaintext) { + t.Fatalf("ciphertext contains plaintext verbatim") + } + + opened, err := svc.Open(context.Background(), "acme", &crypto.Sealed{ + Ciphertext: env.Ciphertext, WrappedDEK: env.WrappedDEK, + }) + if err != nil { + t.Fatalf("open with correct kek: %v", err) + } + if !bytes.Equal(opened, plaintext) { + t.Fatalf("decrypted = %q, want %q", opened, plaintext) + } + + wrongKEK := &fakeKEK{key: make([]byte, crypto.KEKSize)} + wrongSvc := crypto.NewService(wrongKEK) + if _, err := wrongSvc.Open(context.Background(), "acme", &crypto.Sealed{ + Ciphertext: env.Ciphertext, WrappedDEK: env.WrappedDEK, + }); err == nil { + t.Fatalf("expected decrypt failure with wrong kek") + } +} + +func TestClient_ExportFailsOnServerError(t *testing.T) { + mock := newMockS3() + mock.failNext.Store(100) + server := httptest.NewServer(mock.handler(t)) + defer server.Close() + + c := New(testConfig(server.URL), crypto.NewService(newTestKEK())) + err := c.Export(context.Background(), "acme", "docs/x.pdf", []byte("x")) + if !errors.Is(err, ErrExportFailed) { + t.Fatalf("err = %v, want ErrExportFailed", err) + } +} + +func TestDecodeEnvelope_RoundTrip(t *testing.T) { + raw := []byte(`{"wrapped_dek_b64":"YWJj","ciphertext_b64":"eHl6"}`) + env, err := DecodeEnvelope(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if string(env.WrappedDEK) != "abc" || string(env.Ciphertext) != "xyz" { + t.Fatalf("env = %+v", env) + } + back, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(back), "wrapped_dek_b64") { + t.Fatalf("re-marshal lost fields: %s", back) + } +} + +// AC3 / Prüfung 3: ein simulierter Übertragungsfehler löst einen Retry aus - +// über die bereits bestehende Fehlerbehandlung (ENG-05 syncjob.CompleteRunWithRetry), +// nicht über eine zweite, neu erfundene Retry-Schleife. +func TestExport_TransientFailureTriggersRetryViaSyncjob(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + root, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(root) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + store := syncjob.NewStore(pool) + + mock := newMockS3() + mock.failNext.Store(1) // first attempt fails, simulating a transient outage + server := httptest.NewServer(mock.handler(t)) + defer server.Close() + + c := New(testConfig(server.URL), crypto.NewService(newTestKEK())) + + ref := syncjob.Ref{TenantSlug: "acme", ConnectorName: "backup-test", ConnectorInstanceID: t.Name()} + jobID, err := store.Create(context.Background(), ref, 3600) + if err != nil { + t.Fatalf("create job: %v", err) + } + run, err := store.TriggerNow(context.Background(), jobID) + if err != nil { + t.Fatalf("trigger now: %v", err) + } + + exportErr := c.Export(context.Background(), "acme", "docs/retry-test.pdf", []byte("payload")) + if exportErr == nil { + t.Fatalf("expected export to fail on the simulated outage") + } + + policy := syncjob.RetryPolicy{MaxAttempts: 3, BaseDelay: time.Millisecond, MaxDelay: time.Second} + now := time.Now() + status, err := store.CompleteRunWithRetry(context.Background(), run.ID, exportErr, policy, now) + if err != nil { + t.Fatalf("complete run with retry: %v", err) + } + if status != syncjob.RunStatusRetryPending { + t.Fatalf("status = %q, want retry_pending", status) + } + + due, err := store.ClaimDueRetries(context.Background(), now.Add(time.Second)) + if err != nil { + t.Fatalf("claim due retries: %v", err) + } + if len(due) != 1 { + t.Fatalf("claimed %d retries, want 1", len(due)) + } + + retryErr := c.Export(context.Background(), "acme", "docs/retry-test.pdf", []byte("payload")) + if retryErr != nil { + t.Fatalf("retried export should succeed once the outage is over: %v", retryErr) + } + if err := store.CompleteRun(context.Background(), due[0].ID, nil); err != nil { + t.Fatalf("complete retried run: %v", err) + } + final, err := store.GetRun(context.Background(), due[0].ID) + if err != nil { + t.Fatalf("get retried run: %v", err) + } + if final.Status != syncjob.RunStatusSucceeded { + t.Fatalf("retried run status = %q, want succeeded", final.Status) + } +} diff --git a/connect/internal/backupconnector/sigv4.go b/connect/internal/backupconnector/sigv4.go new file mode 100644 index 00000000..6ba482a7 --- /dev/null +++ b/connect/internal/backupconnector/sigv4.go @@ -0,0 +1,95 @@ +// AWS Signature Version 4 request signing, the auth scheme S3-compatible +// object storage (AWS S3, MinIO, Wasabi, ...) expects. Minimal single-request +// signer, no SDK dependency (Connect has none), covering exactly the PUT/HEAD +// calls backupconnector issues. +package backupconnector + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http" + "sort" + "strings" + "time" +) + +const awsV4Algorithm = "AWS4-HMAC-SHA256" + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func signingKey(secretAccessKey, date, region, service string) []byte { + kDate := hmacSHA256([]byte("AWS4"+secretAccessKey), []byte(date)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(service)) + return hmacSHA256(kService, []byte("aws4_request")) +} + +// signRequest adds the x-amz-date, x-amz-content-sha256 and Authorization +// headers required for an S3-compatible request signed with accessKeyID/ +// secretAccessKey for region/service (always "s3" here), at time t. +func signRequest(req *http.Request, body []byte, accessKeyID, secretAccessKey, region, service string, t time.Time) { + amzDate := t.UTC().Format("20060102T150405Z") + dateStamp := t.UTC().Format("20060102") + payloadHash := sha256Hex(body) + + req.Header.Set("x-amz-date", amzDate) + req.Header.Set("x-amz-content-sha256", payloadHash) + if req.Host == "" { + req.Host = req.URL.Host + } + + signedHeaderNames := []string{"host", "x-amz-content-sha256", "x-amz-date"} + sort.Strings(signedHeaderNames) + var canonicalHeaders strings.Builder + for _, name := range signedHeaderNames { + var value string + switch name { + case "host": + value = req.Host + default: + value = req.Header.Get(name) + } + canonicalHeaders.WriteString(name) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(value) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderNames, ";") + + canonicalURI := req.URL.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI, + req.URL.RawQuery, + canonicalHeaders.String(), + signedHeaders, + payloadHash, + }, "\n") + + credentialScope := strings.Join([]string{dateStamp, region, service, "aws4_request"}, "/") + stringToSign := strings.Join([]string{ + awsV4Algorithm, + amzDate, + credentialScope, + sha256Hex([]byte(canonicalRequest)), + }, "\n") + + signature := hex.EncodeToString(hmacSHA256(signingKey(secretAccessKey, dateStamp, region, service), []byte(stringToSign))) + + authHeader := awsV4Algorithm + " Credential=" + accessKeyID + "/" + credentialScope + + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature + req.Header.Set("Authorization", authHeader) +}