package release import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "strings" "testing" ) // A pointer signed for one account's app must not verify at another // account's key for an app of the same name (tenant-isolation audit, // discussion #220). App names are unique only WITHIN an account, so // app+channel alone named "blog/edge" in every account at once: a byte copy // of A's pointer into B's prefix verified there. // // Mutation-mindedness: drop the account comparison from validatePointer and // the cross-account read below verifies again. func TestPointerDoesNotVerifyUnderAnotherAccount(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes")) if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil { t.Fatalf("Promote: %v", err) } raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge")) if err != nil { t.Fatal(err) } if _, err := Channel(ctx, s, pub, "aaa", "blog", "edge"); err != nil { t.Fatalf("own account: %v", err) } if _, err := s.Put(ctx, ChannelKey("bbb", "blog", "edge"), raw, ""); err != nil { t.Fatal(err) } _, err = Channel(ctx, s, pub, "bbb", "blog", "edge") if err == nil { t.Fatal("account aaa's pointer verified under account bbb") } if !strings.Contains(err.Error(), "aaa") { t.Fatalf("refusal should name the account the pointer was signed for: %v", err) } } // Every pointer written from now on carries its account under the // signature, so the binding cannot be removed without breaking it. func TestFreshPointerPayloadNamesItsAccount(t *testing.T) { var sw signedWrapper if err := json.Unmarshal(freshRootWrapper(t), &sw); err != nil { t.Fatal(err) } payload, err := base64.StdEncoding.DecodeString(sw.Payload) if err != nil { t.Fatal(err) } var p ChannelPointer if err := json.Unmarshal(payload, &p); err != nil { t.Fatal(err) } if p.Account != "bab" { t.Fatalf("signed payload account = %q, want bab", p.Account) } } // A pointer written before the binding existed has no account and must keep // verifying wherever it sits: that is most of every production bucket. This // is the residual the binding cannot close by itself — see verifyPointer. func TestAccountlessPointerStillVerifiesAnywhere(t *testing.T) { pub, err := ParsePublicKey(goldenRootPub, "golden") if err != nil { t.Fatal(err) } for _, acct := range []string{"bab", "zzz"} { if _, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), acct, "kass", "canary/platform-adoption"); err != nil { t.Fatalf("golden account-less pointer under %s: %v", acct, err) } } } // A signer cannot produce an unbound pointer: the account is required at // signing, so no code path quietly writes the old shape. func TestSignPointerRequiresAccount(t *testing.T) { priv, _ := testKeys(t) if _, err := signPointer(RootSigner(priv), &ChannelPointer{App: "blog", Channel: "edge", Version: "9f3c1ad"}); err == nil { t.Fatal("signed a pointer with no account") } } // Moving an app between accounts: a bound pointer is re-signed for the new // account (deterministically, so a re-run compares equal), an unbound one // travels as bytes, and the result verifies at the destination only. func TestRebindPointerMovesTheBinding(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() priv, pub := testKeys(t) shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes")) if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil { t.Fatal(err) } raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge")) if err != nil { t.Fatal(err) } if acct, err := PointerAccount(raw); err != nil || acct != "aaa" { t.Fatalf("PointerAccount = %q, %v", acct, err) } moved, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge") if err != nil { t.Fatal(err) } again, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge") if err != nil || string(again) != string(moved) { t.Fatalf("rebind is not deterministic (%v)", err) } got, err := verifyPointer(pub, moved, "bbb", "blog", "edge") if err != nil { t.Fatalf("rebound pointer does not verify at the destination: %v", err) } orig, _ := verifyPointer(pub, raw, "aaa", "blog", "edge") if got.Version != orig.Version || !got.PromotedAt.Equal(orig.PromotedAt) || got.Account != "bbb" { t.Fatalf("rebind changed more than the account: %+v vs %+v", got, orig) } if _, err := verifyPointer(pub, moved, "aaa", "blog", "edge"); err == nil { t.Fatal("rebound pointer still verifies at the source account") } // A pointer from before the binding travels unchanged. golden := []byte(goldenRootSignedWrapper) goldPriv := ed25519.NewKeyFromSeed(goldenSeed()) same, err := RebindPointer(goldPriv, golden, "aaa", "bbb", "kass", "canary/platform-adoption") if err != nil || string(same) != string(golden) { t.Fatalf("account-less pointer was rewritten (%v)", err) } // Rebinding needs the root key the pointer verifies against. wrong := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) if _, err := RebindPointer(wrong, raw, "aaa", "bbb", "blog", "edge"); err == nil { t.Fatal("rebound a pointer with a key it does not verify against") } } // goldenSeed is the deterministic seed golden_test.go's fixture was signed // with (bytes 1..32). func goldenSeed() []byte { seed := make([]byte, ed25519.SeedSize) for i := range seed { seed[i] = byte(i + 1) } return seed }