carlos / platform Public

console: per-app callback signing, and the verifier apps will run

Task 14. The implementation and its first eight tests were written by
codex (gpt-5.6-sol) against the revised plan; the wire-format tests
below, and this message, are mine. Recorded because "who wrote this"
is a fair question to ask of a security primitive.

WHAT IT IS. One deployment master, a per-app key derived at sign time as
HMAC(master, "carlos-scan-webhook-v1" 0x00 sqid "/" app "/" gen), and a
header `t=<unix>,v1=<keyid>:<hex>` over `t + "." + rawbody`. Nothing
per-app is stored: derivation at sign time is what makes app A unable to
forge a callback to app B from a secret it holds. During rotation both
masters sign and the app accepts either, so no app is broken between the
console's rotation and its own re-seal.

VerifyCallback is the deliverable that matters most, and it is the one
§11.3 was missing. "A stale timestamp is refused" and "both masters
verify" are properties of the APP's verifier — tenant code — so without
a reference implementation the tests test nothing anybody runs. The docs
will reproduce it.

WHAT THE PASSING TESTS DID NOT COVER. Nine mutations; five survived, and
four of those had one cause: every test signed and verified with the same
implementation, so nothing pinned the WIRE FORMAT. Dropping the domain
label, dropping the 0x00 separator, changing the MAC delimiter, and
IGNORING THE GENERATION were all invisible.

The generation one is a security defect rather than an untidiness. It
exists so a tenant whose callback secret leaked can be rotated alone; if
it never reached the HMAC, `rotate-callback-key` would report success,
hand back the same key, and the leaked secret would keep working. A
reclaimed app name would inherit the deleted app's key too. Self-
consistent tests cannot see any of it.

So there is now a GOLDEN VECTOR: fixed master, account, app, generation,
body and timestamp, against exact expected hex. It is the only test here
that notices the scheme changing rather than the code, and this is the
one place in the feature that needs it — the bytes are a published
interface the docs hand to app authors to reimplement in other
languages, and every tenant holds a key derived by that function. Change
the derivation and every callback everywhere stops verifying at once,
looking to each tenant like a bug in their own code. Beside it,
TestDerivationSeparatesEveryInput asserts each input actually reaches the
key, so the reason survives even if the numbers are ever regenerated.

ONE BEHAVIOUR CHANGED from what codex wrote: the verifier now IGNORES an
unrecognised header field instead of refusing it. Every tenant runs a
frozen copy of this code, so a verifier that rejected what it did not
recognise would make adding a `v2=` signature alongside `v1=` a breaking
change for every app on the platform. Safe, because the MAC still has to
verify. `t` and at least one `v1` remain required, and both directions
are now pinned.

The remaining survivor is honest: removing the explicit MAC-length check
changes nothing, because hmac.Equal already refuses a length mismatch.
It stays as defence in depth and the test comment says which it is.

AND CODEX FOUND A SPEC BUG, which is why it was asked what it disagreed
with. §6.3's first bullet still specified HMAC(master, sqid + "/" + app)
while the bullets I added below it introduced the domain label and the
generation — the section specified two different schemes. It read both,
followed the later one, and said so. Corrected here, before the wire
format froze.

My own mutation harness was wrong twice tonight in the same way: a -run
pattern that did not match the new test names, so four "survivors" were
tests that never executed. It asserts up front now that the golden
vector is in the set it runs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zrMktR6SbXiFGTDFYLFd7
Paul Campbell pushed by paul@keymail.dev 90ea50b1748f51cc2d4be95e1a6d19d013dd3c4b parent f089085
3 files changed, +399 −2
  • docs/superpowers/specs/2026-09-02-scan-on-demand-design.md +9 −2
  • internal/console/scansign.go +162 −0
  • internal/console/scansign_test.go +228 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
index ba1b8aef8..98a390fe4 100644
--- a/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
+++ b/docs/superpowers/specs/2026-09-02-scan-on-demand-design.md
@@ -626,11 +626,18 @@ nothing*: the console must be able to sign later, and a sealed value it cannot
open is no use to it.
- one boot master, `CARLOS_SCAN_WEBHOOK_KEY`
-- **per-app key derived at sign time**: `HMAC(master, sqid + "/" + app)`.
+- **per-app key derived at sign time**:
+ `HMAC(master, "carlos-scan-webhook-v1" + 0x00 + sqid + "/" + app + "/" + gen)`.
Nothing extra is stored and per-app isolation falls out — without it a single
deployment secret lets app A forge callbacks to app B. The delimiter is
injection-safe: a sqid is `^[a-z]{3,16}$` (`qualified.go:56`) and an app is
- `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`), so neither can contain `/`.
+ `^[a-z][a-z0-9-]{0,31}$` (`release.go:242`), so neither can contain `/`, and
+ `gen` is an integer.
+ *(Corrected: this bullet said `HMAC(master, sqid + "/" + app)` while the
+ bullets below it added the domain label and the generation, so the section
+ specified two different schemes. The implementer read both, followed the
+ later one, and said so — which is the only reason it was caught before the
+ wire format froze.)*
- **written by `store scan grant`**, gated by `storeSealKey`
(`apistore.go:425`) — one config write on an operator verb, never per enqueue
- header `Carlos-Signature: t=<unix>,v1=<keyid>:<hex>[,v1=<keyid>:<hex>]` over
diff --git a/internal/console/scansign.go b/internal/console/scansign.go
new file mode 100644
index 000000000..1038bc233
--- /dev/null
+++ b/internal/console/scansign.go
@@ -0,0 +1,162 @@
+package console
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ callbackKeyDomain = "carlos-scan-webhook-v1"
+ callbackKeyID = "carlos-scan-webhook-key-id"
+ callbackMaxSkew = 5 * time.Minute
+)
+
+// CallbackSigner holds the deployment masters used to sign callbacks. The
+// per-app keys are derived only when they are needed: retaining them here
+// would turn one app's delivered secret into unnecessary console state.
+type CallbackSigner struct {
+ current callbackMaster
+ previous *callbackMaster
+}
+
+type callbackMaster struct {
+ secret []byte
+ id string
+}
+
+// ErrNoCallbackKey refuses a signer that could only produce unauthenticated
+// callbacks. The caller should refuse callback enrollment when this happens.
+var ErrNoCallbackKey = errors.New("console: no scan webhook key (CARLOS_SCAN_WEBHOOK_KEY)")
+
+// NewCallbackSigner builds a signer from CARLOS_SCAN_WEBHOOK_KEY and the
+// optional CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS values supplied by configuration.
+func NewCallbackSigner(current, previous string) (*CallbackSigner, error) {
+ if strings.TrimSpace(current) == "" {
+ return nil, ErrNoCallbackKey
+ }
+ if previous != "" && previous == current {
+ // A rotation that rotated to itself. Accepting it would tell an
+ // operator that the old master was being retired when it was not.
+ return nil, errors.New("console: the previous scan webhook key is identical to the current one — that is not a rotation")
+ }
+ s := &CallbackSigner{current: newCallbackMaster(current)}
+ if previous != "" {
+ p := newCallbackMaster(previous)
+ s.previous = &p
+ }
+ return s, nil
+}
+
+func newCallbackMaster(secret string) callbackMaster {
+ h := hmac.New(sha256.New, []byte(callbackKeyID))
+ h.Write([]byte(secret))
+ return callbackMaster{secret: []byte(secret), id: hex.EncodeToString(h.Sum(nil))[:16]}
+}
+
+// DeriveCallbackKey returns the one key delivered to an app. The domain label
+// prevents another future HMAC(master, value) use from producing the same key;
+// gen lets one tenant rotate a leaked key without rotating the deployment.
+func DeriveCallbackKey(master, sqid, app string, gen int) []byte {
+ h := hmac.New(sha256.New, []byte(master))
+ h.Write([]byte(callbackKeyDomain))
+ h.Write([]byte{0})
+ h.Write([]byte(sqid + "/" + app + "/" + strconv.Itoa(gen)))
+ return h.Sum(nil)
+}
+
+// Sign returns the Carlos-Signature value. During deployment-key rotation it
+// signs with both masters so apps may move to the new derived key gradually.
+func (s *CallbackSigner) Sign(sqid, app string, gen int, body []byte, now time.Time) string {
+ timestamp := strconv.FormatInt(now.Unix(), 10)
+ values := []string{"t=" + timestamp, s.signWith(s.current, sqid, app, gen, timestamp, body)}
+ if s.previous != nil {
+ values = append(values, s.signWith(*s.previous, sqid, app, gen, timestamp, body))
+ }
+ return strings.Join(values, ",")
+}
+
+func (s *CallbackSigner) signWith(master callbackMaster, sqid, app string, gen int, timestamp string, body []byte) string {
+ key := DeriveCallbackKey(string(master.secret), sqid, app, gen)
+ return "v1=" + master.id + ":" + hex.EncodeToString(callbackMAC(key, timestamp, body))
+}
+
+// VerifyCallback is the reference verifier tenant apps run. The key is the
+// app's single derived key, never a deployment master. It tries every v1 entry
+// because key ids identify masters to operators, while an app neither has nor
+// needs the master from which its key came.
+func VerifyCallback(key []byte, header string, body []byte, now time.Time) error {
+ var timestamp string
+ var signatures [][]byte
+ for _, field := range strings.Split(header, ",") {
+ switch {
+ case strings.HasPrefix(field, "t="):
+ if timestamp != "" || len(field) == 2 {
+ return errors.New("console: malformed callback signature timestamp")
+ }
+ timestamp = field[2:]
+ case strings.HasPrefix(field, "v1="):
+ parts := strings.Split(field[3:], ":")
+ if len(parts) != 2 || len(parts[0]) != 16 {
+ return errors.New("console: malformed callback signature")
+ }
+ if _, err := hex.DecodeString(parts[0]); err != nil {
+ return errors.New("console: malformed callback key id")
+ }
+ mac, err := hex.DecodeString(parts[1])
+ if err != nil || len(mac) != sha256.Size {
+ return errors.New("console: malformed callback MAC")
+ }
+ signatures = append(signatures, mac)
+ default:
+ // AN UNKNOWN FIELD IS IGNORED, and that is a forward-compatibility
+ // decision rather than laxness. Every tenant app runs a copy of
+ // this verifier, and the copies are frozen the day they are
+ // written; a verifier that refused what it did not recognise would
+ // make adding a `v2=` signature alongside `v1=` a breaking change
+ // for every app on the platform. Ignoring it is safe because the
+ // MAC still has to verify — an attacker gains nothing by appending
+ // fields. What is NOT optional is below: t and at least one v1.
+ }
+ }
+ if timestamp == "" || len(signatures) == 0 {
+ return errors.New("console: callback signature needs t and v1")
+ }
+ unix, err := strconv.ParseInt(timestamp, 10, 64)
+ if err != nil || strconv.FormatInt(unix, 10) != timestamp {
+ return errors.New("console: malformed callback timestamp")
+ }
+ age := now.Sub(time.Unix(unix, 0))
+ if age < -callbackMaxSkew || age > callbackMaxSkew {
+ return errors.New("console: callback timestamp outside five-minute skew")
+ }
+
+ want := callbackMAC(key, timestamp, body)
+ for _, got := range signatures {
+ if hmac.Equal(got, want) {
+ return nil
+ }
+ }
+ return errors.New("console: callback signature does not verify")
+}
+
+// callbackMAC uses a delimiter because timestamp is decimal digits and cannot
+// contain '.': the first dot therefore splits timestamp and raw body without
+// ambiguity. Verdict tags need length prefixes because an object key can hold
+// any delimiter; copying that encoding here would burden non-Go verifiers for
+// no safety gain.
+//
+// There is deliberately no nonce. A replay within the skew window verifies,
+// but the body is idempotent on (account, app, key, etag, verdict), so a nonce
+// without a durable tenant-side nonce store would add ceremony, not safety.
+func callbackMAC(key []byte, timestamp string, body []byte) []byte {
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte(timestamp))
+ h.Write([]byte{'.'})
+ h.Write(body)
+ return h.Sum(nil)
+}
diff --git a/internal/console/scansign_test.go b/internal/console/scansign_test.go
new file mode 100644
index 000000000..2745a66c2
--- /dev/null
+++ b/internal/console/scansign_test.go
@@ -0,0 +1,228 @@
+package console
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+var callbackTestNow = time.Unix(1_800_000_000, 0)
+
+func callbackTestSigner(t *testing.T, current, previous string) *CallbackSigner {
+ t.Helper()
+ s, err := NewCallbackSigner(current, previous)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+}
+
+func TestSignatureRejectsTamperedBody(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ header := s.Sign("acct", "photos", 0, []byte(`{"verdict":"clean"}`), callbackTestNow)
+ key := DeriveCallbackKey("current", "acct", "photos", 0)
+ if err := VerifyCallback(key, header, []byte(`{"verdict":"infected"}`), callbackTestNow); err == nil {
+ t.Fatal("tampered body verified")
+ }
+}
+
+func TestSignatureIsPerApp(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ body := []byte(`{"verdict":"clean"}`)
+ header := s.Sign("acct", "app-a", 0, body, callbackTestNow)
+ otherKey := DeriveCallbackKey("current", "acct", "app-b", 0)
+ if err := VerifyCallback(otherKey, header, body, callbackTestNow); err == nil {
+ t.Fatal("app A signature verified with app B key")
+ }
+}
+
+func TestSignatureRejectsStaleTimestamp(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ body := []byte("body")
+ key := DeriveCallbackKey("current", "acct", "app", 0)
+ for _, signedAt := range []time.Time{callbackTestNow.Add(-5*time.Minute - time.Second), callbackTestNow.Add(5*time.Minute + time.Second)} {
+ header := s.Sign("acct", "app", 0, body, signedAt)
+ if err := VerifyCallback(key, header, body, callbackTestNow); err == nil {
+ t.Fatalf("timestamp %v verified", signedAt)
+ }
+ }
+}
+
+func TestTwoMasterRotationVerifies(t *testing.T) {
+ s := callbackTestSigner(t, "new", "old")
+ body := []byte("body")
+ header := s.Sign("acct", "app", 3, body, callbackTestNow)
+ for _, master := range []string{"new", "old"} {
+ key := DeriveCallbackKey(master, "acct", "app", 3)
+ if err := VerifyCallback(key, header, body, callbackTestNow); err != nil {
+ t.Fatalf("key derived from %q: %v", master, err)
+ }
+ }
+}
+
+func TestSignatureIsOverRawBodyBytes(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ raw := []byte("{\n \"verdict\": \"clean\"\n}")
+ header := s.Sign("acct", "app", 0, raw, callbackTestNow)
+ key := DeriveCallbackKey("current", "acct", "app", 0)
+ if err := VerifyCallback(key, header, []byte(`{"verdict":"clean"}`), callbackTestNow); err == nil {
+ t.Fatal("re-serialized JSON verified in place of raw body")
+ }
+}
+
+func TestVerifierToleratesTwoSignaturesAndUnknownKeyIds(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ body := []byte("body")
+ header := s.Sign("acct", "app", 0, body, callbackTestNow)
+ unknown := "v1=0000000000000000:" + strings.Repeat("00", 32)
+ header = strings.Replace(header, ",", ","+unknown+",", 1)
+ // A one-master header has no second comma, so append the unknown entry.
+ if !strings.Contains(header, unknown) {
+ header += "," + unknown
+ }
+ key := DeriveCallbackKey("current", "acct", "app", 0)
+ if err := VerifyCallback(key, header, body, callbackTestNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPreviousEqualToCurrentIsRefused(t *testing.T) {
+ if _, err := NewCallbackSigner("same", "same"); err == nil {
+ t.Fatal("equal current and previous masters accepted")
+ }
+}
+
+func TestCallbackVerifierRejectsMalformedHeaders(t *testing.T) {
+ for _, header := range []string{
+ "v1=0000000000000000:" + strings.Repeat("00", 32),
+ "t=1800000000",
+ "t=not-a-number,v1=0000000000000000:" + strings.Repeat("00", 32),
+ "t=1800000000,v1=0000000000000000:",
+ "t=1800000000,v1=0000000000000000:0",
+ "t=1800000000,v1=0000000000000000",
+ } {
+ t.Run(header, func(t *testing.T) {
+ if err := VerifyCallback([]byte("key"), header, []byte("body"), callbackTestNow); err == nil {
+ t.Fatal("malformed header verified")
+ }
+ })
+ }
+}
+
+// TestCallbackWireFormatGoldenVector is the only test here that would notice
+// if the SCHEME changed rather than the code.
+//
+// Every other test in this file signs and verifies with the same
+// implementation, so it passes no matter what goes into the MAC — mutation
+// testing confirmed exactly that: dropping the domain label, dropping the 0x00
+// separator, dropping the generation, and changing the delimiter were ALL
+// invisible. Four silent breakages in the one part of this feature that is a
+// wire contract.
+//
+// It matters more here than anywhere else in the system for two reasons. The
+// docs reproduce VerifyCallback for app authors to reimplement in other
+// languages, so these bytes are a published interface. And every tenant app
+// holds a key derived by this function: if the derivation changes, every
+// callback everywhere stops verifying at once, and it looks to each tenant
+// like a bug in their own code.
+//
+// If this test fails, the scheme changed. That is either a mistake, or a
+// deliberate v2 — and a deliberate v2 adds a signature alongside v1 rather
+// than editing these numbers.
+func TestCallbackWireFormatGoldenVector(t *testing.T) {
+ const (
+ master = "master-fixed-for-the-vector"
+ wantKey = "c7aff5eb65835639b68c63a226767768ac2049fe0b57ab83844691ae5cca1d17"
+ wantHead = "t=1800000000,v1=ccd4f98ffb69f5dd:" +
+ "1c24c96520fc34a1275967165de2a0bac68ac5940cd558fbfc9f8f3367a4b2c8"
+ )
+ body := []byte(`{"account":"bab","app":"titogo","key":"uploads/x.pdf","verdict":"clean"}`)
+
+ if got := hex.EncodeToString(DeriveCallbackKey(master, "bab", "titogo", 7)); got != wantKey {
+ t.Errorf("derived key changed:\n got %s\nwant %s", got, wantKey)
+ }
+ s := callbackTestSigner(t, master, "")
+ if got := s.Sign("bab", "titogo", 7, body, time.Unix(1_800_000_000, 0)); got != wantHead {
+ t.Errorf("header changed:\n got %s\nwant %s", got, wantHead)
+ }
+}
+
+// TestDerivationSeparatesEveryInput: each field that goes into the derivation
+// must change the key when it changes, or it is not really in there.
+//
+// The generation is the one that matters most and the one a self-consistent
+// test cannot see. It exists so a tenant whose callback secret leaked can be
+// rotated on its own; if it did not reach the HMAC, `--rotate-callback-key`
+// would report success, issue the same key back, and the leaked secret would
+// keep working. A reclaimed app name would inherit the deleted app's key too.
+func TestDerivationSeparatesEveryInput(t *testing.T) {
+ base := DeriveCallbackKey("m", "bab", "titogo", 0)
+ for _, tc := range []struct {
+ name string
+ key []byte
+ }{
+ {"a different master", DeriveCallbackKey("m2", "bab", "titogo", 0)},
+ {"a different account", DeriveCallbackKey("m", "zab", "titogo", 0)},
+ {"a different app", DeriveCallbackKey("m", "bab", "other", 0)},
+ {"a different generation", DeriveCallbackKey("m", "bab", "titogo", 1)},
+ } {
+ if hmac.Equal(base, tc.key) {
+ t.Errorf("%s produced the same key — that input does not reach the derivation", tc.name)
+ }
+ }
+ // And the label plus separator are not decoration: without them
+ // HMAC(master, "carlos-scan-webhook-v1\x00bab/titogo/0") would collide with
+ // any other future HMAC(master, x) use that happened to pick that string.
+ // Pinned by the golden vector above; asserted here as a property so the
+ // reason survives even if the numbers are regenerated.
+ if len(base) != sha256.Size {
+ t.Errorf("derived key is %d bytes, want %d", len(base), sha256.Size)
+ }
+}
+
+// TestVerifierToleratesUnknownFields pins the forward-compatibility choice, in
+// both directions: an unrecognised field must not break a verifier that a
+// tenant froze years ago, and a header missing t or v1 must still be refused.
+func TestVerifierToleratesUnknownFields(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ body := []byte("body")
+ key := DeriveCallbackKey("current", "acct", "app", 0)
+ header := s.Sign("acct", "app", 0, body, callbackTestNow)
+
+ // A field from a scheme this verifier has never heard of.
+ if err := VerifyCallback(key, header+",v2=whatever:deadbeef", body, callbackTestNow); err != nil {
+ t.Errorf("an unknown field broke verification: %v — adding v2 later would break every app", err)
+ }
+ if err := VerifyCallback(key, "future=1,"+header, body, callbackTestNow); err != nil {
+ t.Errorf("an unknown leading field broke verification: %v", err)
+ }
+ // But the two that are load-bearing are still required.
+ for _, bad := range []string{
+ "v2=whatever:deadbeef", // no t, no v1
+ "t=" + strconv.FormatInt(callbackTestNow.Unix(), 10) + ",v2=x:y", // t but no v1
+ } {
+ if err := VerifyCallback(key, bad, body, callbackTestNow); err == nil {
+ t.Errorf("header %q verified with no usable signature in it", bad)
+ }
+ }
+}
+
+// TestVerifierRefusesATruncatedMAC. hmac.Equal already refuses a length
+// mismatch, so the explicit size check is defence in depth rather than the
+// guard — pinned anyway, because "it happens to be safe downstream" is how a
+// check gets deleted.
+func TestVerifierRefusesATruncatedMAC(t *testing.T) {
+ s := callbackTestSigner(t, "current", "")
+ body := []byte("body")
+ key := DeriveCallbackKey("current", "acct", "app", 0)
+ header := s.Sign("acct", "app", 0, body, callbackTestNow)
+
+ cut := header[:len(header)-2] // one byte off the MAC
+ if err := VerifyCallback(key, cut, body, callbackTestNow); err == nil {
+ t.Fatal("a truncated MAC verified")
+ }
+}