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.
This commit is contained in:
parent
ca62108f70
commit
d9fa1c55b9
10 changed files with 1075 additions and 0 deletions
113
services/signing-service/README.md
Normal file
113
services/signing-service/README.md
Normal 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`.
|
||||
160
services/signing-service/cmd/signing-service/main.go
Normal file
160
services/signing-service/cmd/signing-service/main.go
Normal 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
|
||||
3
services/signing-service/go.mod
Normal file
3
services/signing-service/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module basicstack.de/proofedmesh/services/signing-service
|
||||
|
||||
go 1.23
|
||||
95
services/signing-service/internal/canonical/canonical.go
Normal file
95
services/signing-service/internal/canonical/canonical.go
Normal 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
|
||||
}
|
||||
136
services/signing-service/internal/httpapi/sign.go
Normal file
136
services/signing-service/internal/httpapi/sign.go
Normal 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}})
|
||||
}
|
||||
218
services/signing-service/internal/httpapi/sign_test.go
Normal file
218
services/signing-service/internal/httpapi/sign_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
services/signing-service/internal/policy/policy.go
Normal file
37
services/signing-service/internal/policy/policy.go
Normal 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
|
||||
}
|
||||
164
services/signing-service/internal/signer/signer.go
Normal file
164
services/signing-service/internal/signer/signer.go
Normal 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
|
||||
}
|
||||
99
services/signing-service/internal/signer/signer_test.go
Normal file
99
services/signing-service/internal/signer/signer_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
50
services/signing-service/internal/token/validator.go
Normal file
50
services/signing-service/internal/token/validator.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue