services: add signing-service (real Ed25519 DSSE, in-memory keyring, AllowAll+Static validators as DEV; OpenBao/OPA/Pocket-ID as stubs). Interop with admin-api attest.HTTPSigner verified: 19 Go-Tests + end-to-end content_hash equality.
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Successful in 18s

This commit is contained in:
CTO Agent 2026-10-25 18:02:44 +00:00
parent ca62108f70
commit d9fa1c55b9
10 changed files with 1075 additions and 0 deletions

View file

@ -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_<slug>` 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=<hex>
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`.

View file

@ -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_<slug>=<hex> 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

View file

@ -0,0 +1,3 @@
module basicstack.de/proofedmesh/services/signing-service
go 1.23

View file

@ -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
}

View file

@ -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}})
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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 <len(type)> <type> <len(payload)> <payload>
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
}

View file

@ -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")
}
}

View file

@ -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
}