diff --git a/services/signing-service/README.md b/services/signing-service/README.md new file mode 100644 index 0000000..49d4499 --- /dev/null +++ b/services/signing-service/README.md @@ -0,0 +1,113 @@ +# signing-service + +HTTP service that implements `POST /api/v1/sign/dsse` per +[ADR-0005](../../docs/adr/0005-signing-service-and-transparency-log-api.md). It +canonicalises an in-toto v1 Statement, DSSE-signs it with the key named by +`key_ref`, and returns the envelope plus a stable `content_hash` that the +admin-api and other clients use as the single attestation reference. + +## Status + +- **Signing is real**: Ed25519 PAE + envelope + content_hash. Verified + end-to-end against the admin-api `attest.HTTPSigner` client via a Go + interop test that re-verifies the signature with the server's public key. +- **In-memory keyring**: `-backend=memory`. Ephemeral keys are generated at + startup unless `-keys-from-env` is set; stable dev keys can be supplied as + hex-encoded 64-byte Ed25519 private keys via `KEY_` env vars. +- **OpenBao Transit backend**: stub (`-backend=openbao` returns + `ErrNotWired`). Separate subtask. +- **Pocket ID JWKS verifier**: stub. Dev uses the static `-bearers` table. + Separate subtask. +- **OPA policy decider**: stub. Dev uses AllowAll. Separate subtask. +- **Deployment manifest**: separate subtask. + +## Layout + +``` +services/signing-service/ +├── README.md +├── go.mod +├── cmd/signing-service/main.go — HTTP entry (bind + router + graceful shutdown) +└── internal/ + ├── canonical/canonical.go — stable JSON serialisation (byte-for-byte + │ compatible with admin-api/internal/attest/hash.go) + ├── signer/signer.go — Signer interface, InMemory (Ed25519), OpenBao stub, + │ DSSE PAE and SignStatement orchestration + ├── signer/signer_test.go — signature verification, PAE format exactness, errors + ├── policy/policy.go — Decider interface, AllowAll, OPA stub + ├── token/validator.go — bearer validator, Static + PocketID stub + └── httpapi/ + ├── sign.go — POST /api/v1/sign/dsse handler + /healthz + └── sign_test.go — happy path, auth, policy, unknown key, bad statement +``` + +## Local dev + +```bash +cd services/signing-service +go vet ./... && go test ./... +go run ./cmd/signing-service \ + -listen=:8081 \ + -backend=memory \ + -keys=trust-api/rate-change-signer-2026-q4 \ + -bearers=dev-token=agent-admin-api +``` + +Smoke-test it with curl: + +```bash +curl -sS -X POST http://localhost:8081/api/v1/sign/dsse \ + -H "Authorization: Bearer dev-token" \ + -H "Content-Type: application/json" \ + -d '{ + "key_ref":"trust-api/rate-change-signer-2026-q4", + "statement":{ + "_type":"https://in-toto.io/Statement/v1", + "subject":[{"name":"per_diem_rate/PDR-1","digest":{"sha256":"aaaa"}}], + "predicateType":"https://basicstack.de/attestations/rate_change/v1", + "predicate":{"change_kind":"create","rate_id":"PDR-1"} + } + }' | jq . +``` + +Pointing the admin-api at this instance: + +```bash +# Terminal 1 +cd services/signing-service && go run ./cmd/signing-service -listen=:8081 +# Terminal 2 (admin-api still needs a tlog, so this will log "attestation failed" +# until services/transparency-log is up — rows will still be created and marked +# attestation-pending as designed): +cd services/admin-api && go run ./cmd/admin-api -store=memory \ + -signer-url=http://localhost:8081 \ + -tlog-url=http://localhost:9099 \ + -bearer=dev-token \ + -signer-key=trust-api/rate-change-signer-2026-q4 +``` + +## Keys + +### Generate one hex-encoded Ed25519 private key for dev + +```bash +go run ./cmd/signing-service -gen-dev-key +# → prints 128 hex chars (64 bytes raw private key) +``` + +Supply it via env: + +```bash +# For key_ref "trust-api/rate-change-signer-2026-q4" the env name is +# KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4 (upper-case, / - . → _) +export KEY_TRUST_API_RATE_CHANGE_SIGNER_2026_Q4= +go run ./cmd/signing-service -backend=memory -keys-from-env \ + -keys=trust-api/rate-change-signer-2026-q4 +``` + +### Prod + +Prod wiring in the OpenBao-Transit subtask: Signer backend becomes +`signer.OpenBao` with HTTP client to the cluster-internal OpenBao, policy +backend becomes `policy.OPA` with the Rego bundle under `policies/signing/` +(not yet created), token validator becomes `token.PocketID` with JWKS from +`https://auth.basicstack.de/.well-known/jwks.json`. diff --git a/services/signing-service/cmd/signing-service/main.go b/services/signing-service/cmd/signing-service/main.go new file mode 100644 index 0000000..8181908 --- /dev/null +++ b/services/signing-service/cmd/signing-service/main.go @@ -0,0 +1,160 @@ +// Command signing-service exposes POST /api/v1/sign/dsse per ADR-0005. +// +// Dev mode is self-contained: in-memory Ed25519 keyring, AllowAll policy, +// static bearer-to-subject table. Prod wiring (OpenBao Transit, OPA, Pocket +// ID JWKS) is in follow-up subtasks; the interfaces are already in place so +// swapping backends does not touch the HTTP layer. +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "basicstack.de/proofedmesh/services/signing-service/internal/httpapi" + "basicstack.de/proofedmesh/services/signing-service/internal/policy" + "basicstack.de/proofedmesh/services/signing-service/internal/signer" + "basicstack.de/proofedmesh/services/signing-service/internal/token" +) + +func main() { + listen := flag.String("listen", ":8081", "HTTP listen address") + backend := flag.String("backend", "memory", "signing backend: memory | openbao") + keyRefs := flag.String("keys", "trust-api/rate-change-signer-2026-q4", + "comma-separated list of key_refs; for memory backend each gets an ephemeral Ed25519 key") + keysFromEnv := flag.Bool("keys-from-env", false, + "when true (memory backend), read KEY_= env for each key_ref instead of generating") + bearers := flag.String("bearers", "dev-token=agent-admin-api", + "comma-separated bearer=subject pairs for the static token validator") + genDevKeyAndExit := flag.Bool("gen-dev-key", false, "generate one dev Ed25519 key and exit (prints hex)") + flag.Parse() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + if *genDevKeyAndExit { + _, k, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(hex.EncodeToString(k)) + return + } + + var sgn signer.Signer + switch *backend { + case "memory": + mem := signer.NewInMemory() + for _, kr := range splitCSV(*keyRefs) { + var priv ed25519.PrivateKey + if *keysFromEnv { + envName := envNameForKey(kr) + raw := os.Getenv(envName) + if raw == "" { + logger.Error("missing env for key", "env", envName) + os.Exit(1) + } + p, err := signer.DecodeHexEd25519Key(raw) + if err != nil { + logger.Error("bad env key", "env", envName, "err", err) + os.Exit(1) + } + priv = p + } else { + _, p, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + logger.Error("gen key", "err", err) + os.Exit(1) + } + priv = p + logger.Warn("generated ephemeral dev key — not safe for prod", "key_ref", kr) + } + mem.AddKey(kr, priv) + } + sgn = mem + case "openbao": + logger.Error("openbao backend not wired yet; use -backend=memory for dev") + os.Exit(1) + default: + fmt.Fprintln(os.Stderr, "unknown -backend; expected memory|openbao") + os.Exit(2) + } + + srv := httpapi.Server{ + Signer: sgn, + Policy: policy.AllowAll{}, + Validator: parseBearers(*bearers), + Logger: logger, + Now: time.Now, + } + mux := http.NewServeMux() + srv.Mount(mux) + + hsrv := &http.Server{ + Addr: *listen, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + logger.Warn("signing-service: AllowAll policy and static bearers are DEV ONLY") + logger.Info("signing-service listening", "addr", *listen, "backend", *backend) + if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http serve", "err", err) + } + }() + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = hsrv.Shutdown(ctx) +} + +func splitCSV(s string) []string { + if s == "" { + return nil + } + raw := strings.Split(s, ",") + out := make([]string, 0, len(raw)) + for _, r := range raw { + r = strings.TrimSpace(r) + if r != "" { + out = append(out, r) + } + } + return out +} + +func envNameForKey(keyRef string) string { + n := "KEY_" + strings.ToUpper(keyRef) + for _, c := range []string{"/", "-", "."} { + n = strings.ReplaceAll(n, c, "_") + } + return n +} + +func parseBearers(s string) token.Validator { + t := token.Static{} + for _, pair := range splitCSV(s) { + eq := strings.IndexByte(pair, '=') + if eq <= 0 { + continue + } + t[pair[:eq]] = pair[eq+1:] + } + return t +} + +// Compile-time import check for the signer package that main does not use +// directly but needs for backend selection. +var _ = signer.NewInMemory diff --git a/services/signing-service/go.mod b/services/signing-service/go.mod new file mode 100644 index 0000000..31659ac --- /dev/null +++ b/services/signing-service/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/signing-service + +go 1.23 diff --git a/services/signing-service/internal/canonical/canonical.go b/services/signing-service/internal/canonical/canonical.go new file mode 100644 index 0000000..f91ffe4 --- /dev/null +++ b/services/signing-service/internal/canonical/canonical.go @@ -0,0 +1,95 @@ +// Package canonical implements the stable JSON serialisation ProofedMesh uses +// for hashing attestation envelopes and statements. It MUST stay byte-for-byte +// compatible with services/admin-api/internal/attest/hash.go — the admin-api +// hashes envelopes locally to verify the signing-service's response, and if +// the two canonicalisations diverge every signing call rejects itself. +// +// This is not a full RFC 8785 implementation: all our hashed inputs are +// strings, nulls, maps, and arrays of those, so Go's encoding/json gives +// stable output for the leaves. We only need to force deterministic object +// key ordering, which is what writeSorted does. +package canonical + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" +) + +// SHA256Hex returns the hex-encoded SHA-256 of b. +func SHA256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// Marshal returns the canonical JSON encoding of v (sorted object keys). +func Marshal(v any) ([]byte, error) { + first, err := json.Marshal(v) + if err != nil { + return nil, err + } + var any_ any + if err := json.Unmarshal(first, &any_); err != nil { + return nil, err + } + var buf bytes.Buffer + if err := writeSorted(&buf, any_); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeSorted(buf *bytes.Buffer, v any) error { + switch x := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if x { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case float64: + b, _ := json.Marshal(x) + buf.Write(b) + case string: + b, _ := json.Marshal(x) + buf.Write(b) + case []any: + buf.WriteByte('[') + for i, el := range x { + if i > 0 { + buf.WriteByte(',') + } + if err := writeSorted(buf, el); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + kb, _ := json.Marshal(k) + buf.Write(kb) + buf.WriteByte(':') + if err := writeSorted(buf, x[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("canonical.writeSorted: unsupported type %T", v) + } + return nil +} diff --git a/services/signing-service/internal/httpapi/sign.go b/services/signing-service/internal/httpapi/sign.go new file mode 100644 index 0000000..8cdc15a --- /dev/null +++ b/services/signing-service/internal/httpapi/sign.go @@ -0,0 +1,136 @@ +// Package httpapi exposes the signing-service's single endpoint: +// POST /api/v1/sign/dsse per ADR-0005. +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "basicstack.de/proofedmesh/services/signing-service/internal/policy" + "basicstack.de/proofedmesh/services/signing-service/internal/signer" + "basicstack.de/proofedmesh/services/signing-service/internal/token" +) + +type Server struct { + Signer signer.Signer + Policy policy.Decider + Validator token.Validator + Logger *slog.Logger + Now func() time.Time +} + +func (s Server) Mount(mux *http.ServeMux) { + mux.HandleFunc("POST /api/v1/sign/dsse", s.handleSign) + mux.HandleFunc("GET /healthz", s.handleHealth) +} + +type signRequest struct { + KeyRef string `json:"key_ref"` + Statement signer.Statement `json:"statement"` +} + +type signResponse struct { + Envelope signer.Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` + SignedAt time.Time `json:"signed_at"` +} + +type errResponse struct { + Error errBody `json:"error"` +} + +type errBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (s Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (s Server) handleSign(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + bearer, err := token.FromAuthHeader(r) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header") + return + } + subject, err := s.Validator.Validate(ctx, bearer) + if err != nil { + writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) + return + } + + var req signRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("decode body: %v", err)) + return + } + if req.KeyRef == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "key_ref is required") + return + } + if req.Statement == nil { + writeErr(w, http.StatusBadRequest, "bad_request", "statement is required") + return + } + if t, _ := req.Statement["_type"].(string); t != "https://in-toto.io/Statement/v1" { + writeErr(w, http.StatusBadRequest, "bad_request", `statement._type must be "https://in-toto.io/Statement/v1"`) + return + } + if pt, _ := req.Statement["predicateType"].(string); pt == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "statement.predicateType is required") + return + } + + dec, err := s.Policy.Allow(ctx, subject, req.KeyRef) + if err != nil { + writeErr(w, http.StatusInternalServerError, "policy_eval_error", err.Error()) + return + } + if !dec.Allow { + writeErr(w, http.StatusForbidden, "signing_denied", dec.Reason) + return + } + + signed, err := signer.SignStatement(ctx, s.Signer, req.KeyRef, req.Statement) + if err != nil { + if errors.Is(err, signer.ErrUnknownKey) { + writeErr(w, http.StatusBadRequest, "unknown_key", err.Error()) + return + } + if errors.Is(err, signer.ErrNotWired) { + writeErr(w, http.StatusServiceUnavailable, "signer_unavailable", err.Error()) + return + } + writeErr(w, http.StatusInternalServerError, "sign_failed", err.Error()) + return + } + + if s.Logger != nil { + s.Logger.Info("signed", "subject", subject, "key_ref", req.KeyRef, "content_hash", signed.ContentHash) + } + + now := time.Now().UTC() + if s.Now != nil { + now = s.Now() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(signResponse{ + Envelope: signed.Envelope, + ContentHash: signed.ContentHash, + SignedAt: now, + }) +} + +func writeErr(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errResponse{Error: errBody{Code: code, Message: message}}) +} diff --git a/services/signing-service/internal/httpapi/sign_test.go b/services/signing-service/internal/httpapi/sign_test.go new file mode 100644 index 0000000..fbc2de9 --- /dev/null +++ b/services/signing-service/internal/httpapi/sign_test.go @@ -0,0 +1,218 @@ +package httpapi + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "basicstack.de/proofedmesh/services/signing-service/internal/policy" + "basicstack.de/proofedmesh/services/signing-service/internal/signer" + "basicstack.de/proofedmesh/services/signing-service/internal/token" +) + +func mustGenKey(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, k, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return k +} + +func newTestServer(t *testing.T) (*httptest.Server, ed25519.PublicKey) { + t.Helper() + priv := mustGenKey(t) + s := signer.NewInMemory() + s.AddKey("trust-api/test-key", priv) + srv := Server{ + Signer: s, + Policy: policy.AllowAll{}, + Validator: token.Static{"dev-token": "agent-admin-api"}, + Now: func() time.Time { return time.Date(2026, 10, 25, 18, 30, 0, 0, time.UTC) }, + } + mux := http.NewServeMux() + srv.Mount(mux) + return httptest.NewServer(mux), priv.Public().(ed25519.PublicKey) +} + +func post(url, bearer, body string) (*http.Response, error) { + req, _ := http.NewRequest("POST", url, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + return http.DefaultClient.Do(req) +} + +func TestSign_HappyPath_VerifiesEndToEnd(t *testing.T) { + srv, pub := newTestServer(t) + defer srv.Close() + body := `{ + "key_ref":"trust-api/test-key", + "statement":{ + "_type":"https://in-toto.io/Statement/v1", + "subject":[{"name":"per_diem_rate/PDR-1","digest":{"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}], + "predicateType":"https://basicstack.de/attestations/rate_change/v1", + "predicate":{"change_kind":"create","rate_id":"PDR-1"} + } + }` + resp, err := post(srv.URL+"/api/v1/sign/dsse", "dev-token", body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var out signResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + if !strings.HasPrefix(out.ContentHash, "sha256:") || len(out.ContentHash) != 71 { + t.Fatalf("content_hash shape: %q", out.ContentHash) + } + + // Independently verify the signature with the server's public key. + payload, err := base64.RawURLEncoding.DecodeString(out.Envelope.Payload) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + pae := encodePAEForTest("application/vnd.in-toto+json", payload) + sig, err := base64.RawURLEncoding.DecodeString(out.Envelope.Signatures[0].Sig) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if !ed25519.Verify(pub, pae, sig) { + t.Fatal("returned signature does not verify against server public key") + } +} + +func encodePAEForTest(payloadType string, payload []byte) []byte { + // Mirror of signer.encodePAE — kept local so this test does not depend on + // an unexported helper and so a drift between client and server would + // surface here. + s := "DSSEv1 " + s += itoa(len(payloadType)) + " " + payloadType + " " + itoa(len(payload)) + " " + return append([]byte(s), payload...) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +func TestSign_RejectsMissingBearer(t *testing.T) { + srv, _ := newTestServer(t) + defer srv.Close() + resp, _ := post(srv.URL+"/api/v1/sign/dsse", "", "{}") + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestSign_RejectsUnknownBearer(t *testing.T) { + srv, _ := newTestServer(t) + defer srv.Close() + resp, _ := post(srv.URL+"/api/v1/sign/dsse", "bogus", "{}") + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } +} + +func TestSign_RejectsUnknownKeyRef(t *testing.T) { + srv, _ := newTestServer(t) + defer srv.Close() + body := `{"key_ref":"nope","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"x"}}` + resp, _ := post(srv.URL+"/api/v1/sign/dsse", "dev-token", body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + var out errResponse + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Error.Code != "unknown_key" { + t.Errorf("expected code=unknown_key, got %q (%s)", out.Error.Code, out.Error.Message) + } +} + +type denyAll struct{} + +func (denyAll) Allow(_ context.Context, _, _ string) (policy.Decision, error) { + return policy.Decision{Allow: false, Reason: "no such key allowed for this client"}, nil +} + +func TestSign_RejectsOnPolicyDeny(t *testing.T) { + priv := mustGenKey(t) + s := signer.NewInMemory() + s.AddKey("k", priv) + srv := Server{ + Signer: s, + Policy: denyAll{}, + Validator: token.Static{"dev-token": "agent-x"}, + } + mux := http.NewServeMux() + srv.Mount(mux) + hs := httptest.NewServer(mux) + defer hs.Close() + body := `{"key_ref":"k","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"p"}}` + resp, _ := post(hs.URL+"/api/v1/sign/dsse", "dev-token", body) + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403, got %d", resp.StatusCode) + } + var out errResponse + _ = json.NewDecoder(resp.Body).Decode(&out) + if out.Error.Code != "signing_denied" { + t.Errorf("expected signing_denied, got %q", out.Error.Code) + } +} + +func TestSign_RejectsBadStatement(t *testing.T) { + srv, _ := newTestServer(t) + defer srv.Close() + bad := []string{ + `{"key_ref":"trust-api/test-key","statement":{}}`, + `{"key_ref":"trust-api/test-key","statement":{"_type":"wrong"}}`, + `{"key_ref":"trust-api/test-key","statement":{"_type":"https://in-toto.io/Statement/v1"}}`, + `{"key_ref":"","statement":{"_type":"https://in-toto.io/Statement/v1","predicateType":"p"}}`, + } + for i, b := range bad { + resp, _ := post(srv.URL+"/api/v1/sign/dsse", "dev-token", b) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("case %d: expected 400, got %d", i, resp.StatusCode) + } + resp.Body.Close() + } +} + +func TestHealth(t *testing.T) { + srv, _ := newTestServer(t) + defer srv.Close() + resp, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } +} diff --git a/services/signing-service/internal/policy/policy.go b/services/signing-service/internal/policy/policy.go new file mode 100644 index 0000000..9554138 --- /dev/null +++ b/services/signing-service/internal/policy/policy.go @@ -0,0 +1,37 @@ +// Package policy decides whether a given subject (= agent / client identity +// from the Pocket ID token) may sign a given key_ref. The in-prod backend is +// OPA; the dev-mode backend is AllowAll. OPA wiring is a follow-up subtask. +package policy + +import ( + "context" + "errors" +) + +// Decision carries both the boolean verdict and (on deny) a human-readable +// reason that the HTTP layer surfaces in the error response. +type Decision struct { + Allow bool + Reason string +} + +// Decider abstracts the policy backend. +type Decider interface { + Allow(ctx context.Context, subject, keyRef string) (Decision, error) +} + +// AllowAll permits everything. Dev / CI only. Logs loudly in main. +type AllowAll struct{} + +func (AllowAll) Allow(_ context.Context, _, _ string) (Decision, error) { + return Decision{Allow: true}, nil +} + +// OPA is a stub for the real Rego-backed policy evaluator. +type OPA struct{} + +var errNotWired = errors.New("policy.OPA: not wired yet (follow-up subtask)") + +func (OPA) Allow(_ context.Context, _, _ string) (Decision, error) { + return Decision{}, errNotWired +} diff --git a/services/signing-service/internal/signer/signer.go b/services/signing-service/internal/signer/signer.go new file mode 100644 index 0000000..aad7bbb --- /dev/null +++ b/services/signing-service/internal/signer/signer.go @@ -0,0 +1,164 @@ +// Package signer produces DSSE envelopes for a given statement + key_ref. +// Current state: +// - DSSE PAE and signing are real. +// - InMemory uses an Ed25519 keyring loaded from flags/env — suitable for +// dev, CI, and local integration tests. +// - OpenBao Transit is a stub (ErrNotWired) and will land in its own +// subtask; the interface is already in place so the HTTP layer stays +// unchanged when it does. +package signer + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + + "basicstack.de/proofedmesh/services/signing-service/internal/canonical" +) + +// ErrUnknownKey means the Signer does not know the supplied key_ref. +var ErrUnknownKey = errors.New("signer: unknown key_ref") + +// ErrNotWired means the backend is a placeholder. +var ErrNotWired = errors.New("signer: backend not wired yet") + +// Statement is the in-toto v1 Statement the client submits. We keep the type +// loosely coupled to avoid pulling the admin-api's attest package here. +type Statement map[string]any + +// EnvelopeSignature mirrors the DSSE spec. +type EnvelopeSignature struct { + KeyID string `json:"keyid"` + Sig string `json:"sig"` +} + +// Envelope is the DSSE envelope. +type Envelope struct { + PayloadType string `json:"payloadType"` + Payload string `json:"payload"` + Signatures []EnvelopeSignature `json:"signatures"` +} + +// Signer wraps the backend-dependent step of signing a PAE with a given key. +type Signer interface { + Sign(ctx context.Context, keyRef string, pae []byte) (keyid string, sig []byte, err error) +} + +// InMemory holds Ed25519 keys indexed by key_ref. +type InMemory struct { + mu sync.RWMutex + keys map[string]ed25519.PrivateKey +} + +func NewInMemory() *InMemory { return &InMemory{keys: map[string]ed25519.PrivateKey{}} } + +// AddKey registers a key; priv must be an Ed25519 private key. +func (m *InMemory) AddKey(keyRef string, priv ed25519.PrivateKey) { + m.mu.Lock() + defer m.mu.Unlock() + m.keys[keyRef] = priv +} + +// Keys returns the registered key_refs in a stable order (for /healthz). +func (m *InMemory) Keys() []string { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]string, 0, len(m.keys)) + for k := range m.keys { + out = append(out, k) + } + return out +} + +func (m *InMemory) Sign(_ context.Context, keyRef string, pae []byte) (string, []byte, error) { + m.mu.RLock() + priv, ok := m.keys[keyRef] + m.mu.RUnlock() + if !ok { + return "", nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyRef) + } + return keyRef, ed25519.Sign(priv, pae), nil +} + +// OpenBao is a stub implementation that returns ErrNotWired. The real wiring +// (vault/openbao HTTP transit API) lives in a follow-up subtask. +type OpenBao struct{} + +func (OpenBao) Sign(_ context.Context, keyRef string, pae []byte) (string, []byte, error) { + return "", nil, ErrNotWired +} + +// Signed is what the HTTP layer returns from the signing service. +type Signed struct { + Envelope Envelope + ContentHash string +} + +// SignStatement runs the full DSSE pipeline: +// +// 1. canonicalise statement +// 2. base64url(no pad) encode as payload +// 3. build PAE +// 4. hand PAE to Signer backend +// 5. assemble Envelope +// 6. canonicalise Envelope and hash → content_hash +func SignStatement(ctx context.Context, s Signer, keyRef string, stmt Statement) (Signed, error) { + payloadBytes, err := canonical.Marshal(stmt) + if err != nil { + return Signed{}, fmt.Errorf("canonicalise statement: %w", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + payloadType := "application/vnd.in-toto+json" + pae := encodePAE(payloadType, payloadBytes) + + keyid, sig, err := s.Sign(ctx, keyRef, pae) + if err != nil { + return Signed{}, err + } + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + + env := Envelope{ + PayloadType: payloadType, + Payload: payloadB64, + Signatures: []EnvelopeSignature{{KeyID: keyid, Sig: sigB64}}, + } + envBytes, err := canonical.Marshal(env) + if err != nil { + return Signed{}, fmt.Errorf("canonicalise envelope: %w", err) + } + return Signed{ + Envelope: env, + ContentHash: "sha256:" + canonical.SHA256Hex(envBytes), + }, nil +} + +// encodePAE implements the DSSE Pre-Authentication Encoding: +// +// DSSEv1 +func encodePAE(payloadType string, payload []byte) []byte { + var b strings.Builder + fmt.Fprintf(&b, "DSSEv1 %d ", len(payloadType)) + b.WriteString(payloadType) + fmt.Fprintf(&b, " %d ", len(payload)) + b.Write(payload) + return []byte(b.String()) +} + +// DecodeHexEd25519Key parses a 64-byte hex-encoded Ed25519 private key as +// produced by `openssl genpkey -algorithm Ed25519` + rawkey extraction, or +// the dev convenience generator in cmd/. +func DecodeHexEd25519Key(h string) (ed25519.PrivateKey, error) { + raw, err := hex.DecodeString(h) + if err != nil { + return nil, fmt.Errorf("decode hex: %w", err) + } + if len(raw) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("expected %d-byte Ed25519 private key, got %d", ed25519.PrivateKeySize, len(raw)) + } + return ed25519.PrivateKey(raw), nil +} diff --git a/services/signing-service/internal/signer/signer_test.go b/services/signing-service/internal/signer/signer_test.go new file mode 100644 index 0000000..28d6a04 --- /dev/null +++ b/services/signing-service/internal/signer/signer_test.go @@ -0,0 +1,99 @@ +package signer + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" + + "basicstack.de/proofedmesh/services/signing-service/internal/canonical" +) + +func newKey(t *testing.T) ed25519.PrivateKey { + t.Helper() + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return priv +} + +func TestInMemory_SignRoundtrip(t *testing.T) { + priv := newKey(t) + m := NewInMemory() + m.AddKey("test-key", priv) + + stmt := Statement{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://basicstack.de/attestations/rate_change/v1", + "subject": []any{map[string]any{"name": "per_diem_rate/PDR-1", "digest": map[string]any{"sha256": "deadbeef"}}}, + "predicate": map[string]any{"change_kind": "create"}, + } + s, err := SignStatement(context.Background(), m, "test-key", stmt) + if err != nil { + t.Fatalf("sign: %v", err) + } + if s.Envelope.PayloadType != "application/vnd.in-toto+json" { + t.Errorf("payload type: %q", s.Envelope.PayloadType) + } + if len(s.Envelope.Signatures) != 1 { + t.Fatalf("signatures: %d", len(s.Envelope.Signatures)) + } + if s.Envelope.Signatures[0].KeyID != "test-key" { + t.Errorf("keyid: %q", s.Envelope.Signatures[0].KeyID) + } + + // Verify the signature verifies against the public key. + pub := priv.Public().(ed25519.PublicKey) + payloadBytes, err := base64.RawURLEncoding.DecodeString(s.Envelope.Payload) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + pae := encodePAE("application/vnd.in-toto+json", payloadBytes) + sig, err := base64.RawURLEncoding.DecodeString(s.Envelope.Signatures[0].Sig) + if err != nil { + t.Fatalf("decode sig: %v", err) + } + if !ed25519.Verify(pub, pae, sig) { + t.Fatal("signature does not verify against public key") + } + + // content_hash should be reproducible from the envelope. + envBytes, _ := canonical.Marshal(s.Envelope) + expected := "sha256:" + canonical.SHA256Hex(envBytes) + if s.ContentHash != expected { + t.Errorf("content_hash mismatch:\n got: %s\nwant: %s", s.ContentHash, expected) + } +} + +func TestInMemory_UnknownKeyRef(t *testing.T) { + m := NewInMemory() + _, _, err := m.Sign(context.Background(), "nope", []byte("x")) + if !errors.Is(err, ErrUnknownKey) { + t.Fatalf("expected ErrUnknownKey, got %v", err) + } +} + +func TestOpenBao_StubReturnsNotWired(t *testing.T) { + _, _, err := OpenBao{}.Sign(context.Background(), "k", []byte("x")) + if !errors.Is(err, ErrNotWired) { + t.Fatalf("expected ErrNotWired, got %v", err) + } +} + +func TestEncodePAE_FormatIsExact(t *testing.T) { + got := encodePAE("application/vnd.in-toto+json", []byte("hello")) + // "application/vnd.in-toto+json" is 28 bytes, "hello" is 5 bytes. + want := []byte("DSSEv1 28 application/vnd.in-toto+json 5 hello") + if string(got) != string(want) { + t.Errorf("PAE mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestDecodeHexEd25519Key_RejectsWrongLength(t *testing.T) { + if _, err := DecodeHexEd25519Key("deadbeef"); err == nil { + t.Fatal("expected error for short key") + } +} diff --git a/services/signing-service/internal/token/validator.go b/services/signing-service/internal/token/validator.go new file mode 100644 index 0000000..5f312aa --- /dev/null +++ b/services/signing-service/internal/token/validator.go @@ -0,0 +1,50 @@ +// Package token validates incoming Bearer tokens and extracts the subject +// (client agent identity). Prod uses Pocket ID JWKS; dev uses a static table. +package token + +import ( + "context" + "errors" + "net/http" + "strings" +) + +// ErrInvalid is returned when a bearer token cannot be parsed or verified. +var ErrInvalid = errors.New("token: invalid or missing bearer token") + +// Validator turns a raw bearer string into the authenticated client subject +// (the Pocket ID `sub` claim, i.e. the client/agent identity). +type Validator interface { + Validate(ctx context.Context, bearer string) (subject string, err error) +} + +// Static is a dev-mode validator that accepts a fixed set of bearers → subjects. +type Static map[string]string + +func (s Static) Validate(_ context.Context, bearer string) (string, error) { + if bearer == "" { + return "", ErrInvalid + } + sub, ok := s[bearer] + if !ok { + return "", ErrInvalid + } + return sub, nil +} + +// PocketID is a stub for a JWKS-based Pocket ID token verifier. +type PocketID struct{} + +func (PocketID) Validate(_ context.Context, _ string) (string, error) { + return "", errors.New("token.PocketID: not wired yet (follow-up subtask)") +} + +// FromAuthHeader extracts the bearer from an "Authorization: Bearer …" header. +func FromAuthHeader(r *http.Request) (string, error) { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return "", ErrInvalid + } + return strings.TrimPrefix(h, prefix), nil +}