| 1 | package release |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // A pointer signed for one account's app must not verify at another |
| 13 | // account's key for an app of the same name (tenant-isolation audit, |
| 14 | // discussion #220). App names are unique only WITHIN an account, so |
| 15 | // app+channel alone named "blog/edge" in every account at once: a byte copy |
| 16 | // of A's pointer into B's prefix verified there. |
| 17 | // |
| 18 | // Mutation-mindedness: drop the account comparison from validatePointer and |
| 19 | // the cross-account read below verifies again. |
| 20 | func TestPointerDoesNotVerifyUnderAnotherAccount(t *testing.T) { |
| 21 | s := NewDirStore(t.TempDir()) |
| 22 | ctx := context.Background() |
| 23 | priv, pub := testKeys(t) |
| 24 | shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes")) |
| 25 | if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil { |
| 26 | t.Fatalf("Promote: %v", err) |
| 27 | } |
| 28 | raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge")) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if _, err := Channel(ctx, s, pub, "aaa", "blog", "edge"); err != nil { |
| 33 | t.Fatalf("own account: %v", err) |
| 34 | } |
| 35 | if _, err := s.Put(ctx, ChannelKey("bbb", "blog", "edge"), raw, ""); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | _, err = Channel(ctx, s, pub, "bbb", "blog", "edge") |
| 39 | if err == nil { |
| 40 | t.Fatal("account aaa's pointer verified under account bbb") |
| 41 | } |
| 42 | if !strings.Contains(err.Error(), "aaa") { |
| 43 | t.Fatalf("refusal should name the account the pointer was signed for: %v", err) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // Every pointer written from now on carries its account under the |
| 48 | // signature, so the binding cannot be removed without breaking it. |
| 49 | func TestFreshPointerPayloadNamesItsAccount(t *testing.T) { |
| 50 | var sw signedWrapper |
| 51 | if err := json.Unmarshal(freshRootWrapper(t), &sw); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | var p ChannelPointer |
| 59 | if err := json.Unmarshal(payload, &p); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if p.Account != "bab" { |
| 63 | t.Fatalf("signed payload account = %q, want bab", p.Account) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // A pointer written before the binding existed has no account and must keep |
| 68 | // verifying wherever it sits: that is most of every production bucket. This |
| 69 | // is the residual the binding cannot close by itself — see verifyPointer. |
| 70 | func TestAccountlessPointerStillVerifiesAnywhere(t *testing.T) { |
| 71 | pub, err := ParsePublicKey(goldenRootPub, "golden") |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | for _, acct := range []string{"bab", "zzz"} { |
| 76 | if _, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), acct, "kass", "canary/platform-adoption"); err != nil { |
| 77 | t.Fatalf("golden account-less pointer under %s: %v", acct, err) |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // A signer cannot produce an unbound pointer: the account is required at |
| 83 | // signing, so no code path quietly writes the old shape. |
| 84 | func TestSignPointerRequiresAccount(t *testing.T) { |
| 85 | priv, _ := testKeys(t) |
| 86 | if _, err := signPointer(RootSigner(priv), &ChannelPointer{App: "blog", Channel: "edge", Version: "9f3c1ad"}); err == nil { |
| 87 | t.Fatal("signed a pointer with no account") |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // Moving an app between accounts: a bound pointer is re-signed for the new |
| 92 | // account (deterministically, so a re-run compares equal), an unbound one |
| 93 | // travels as bytes, and the result verifies at the destination only. |
| 94 | func TestRebindPointerMovesTheBinding(t *testing.T) { |
| 95 | s := NewDirStore(t.TempDir()) |
| 96 | ctx := context.Background() |
| 97 | priv, pub := testKeys(t) |
| 98 | shipRelease(t, s, "aaa", "blog", "9f3c1ad", "linux-arm64", []byte("bytes")) |
| 99 | if err := Promote(ctx, s, priv, "aaa", "blog", "edge", "9f3c1ad"); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | raw, _, err := s.Get(ctx, ChannelKey("aaa", "blog", "edge")) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if acct, err := PointerAccount(raw); err != nil || acct != "aaa" { |
| 107 | t.Fatalf("PointerAccount = %q, %v", acct, err) |
| 108 | } |
| 109 | moved, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge") |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | again, err := RebindPointer(priv, raw, "aaa", "bbb", "blog", "edge") |
| 114 | if err != nil || string(again) != string(moved) { |
| 115 | t.Fatalf("rebind is not deterministic (%v)", err) |
| 116 | } |
| 117 | got, err := verifyPointer(pub, moved, "bbb", "blog", "edge") |
| 118 | if err != nil { |
| 119 | t.Fatalf("rebound pointer does not verify at the destination: %v", err) |
| 120 | } |
| 121 | orig, _ := verifyPointer(pub, raw, "aaa", "blog", "edge") |
| 122 | if got.Version != orig.Version || !got.PromotedAt.Equal(orig.PromotedAt) || got.Account != "bbb" { |
| 123 | t.Fatalf("rebind changed more than the account: %+v vs %+v", got, orig) |
| 124 | } |
| 125 | if _, err := verifyPointer(pub, moved, "aaa", "blog", "edge"); err == nil { |
| 126 | t.Fatal("rebound pointer still verifies at the source account") |
| 127 | } |
| 128 | // A pointer from before the binding travels unchanged. |
| 129 | golden := []byte(goldenRootSignedWrapper) |
| 130 | goldPriv := ed25519.NewKeyFromSeed(goldenSeed()) |
| 131 | same, err := RebindPointer(goldPriv, golden, "aaa", "bbb", "kass", "canary/platform-adoption") |
| 132 | if err != nil || string(same) != string(golden) { |
| 133 | t.Fatalf("account-less pointer was rewritten (%v)", err) |
| 134 | } |
| 135 | // Rebinding needs the root key the pointer verifies against. |
| 136 | wrong := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) |
| 137 | if _, err := RebindPointer(wrong, raw, "aaa", "bbb", "blog", "edge"); err == nil { |
| 138 | t.Fatal("rebound a pointer with a key it does not verify against") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // goldenSeed is the deterministic seed golden_test.go's fixture was signed |
| 143 | // with (bytes 1..32). |
| 144 | func goldenSeed() []byte { |
| 145 | seed := make([]byte, ed25519.SeedSize) |
| 146 | for i := range seed { |
| 147 | seed[i] = byte(i + 1) |
| 148 | } |
| 149 | return seed |
| 150 | } |
| 151 | |