package webapp // Round 5 — the target is round 4's own fixes (b616c94) on the hub: the // account binding ownJournal grew (DeviceRegistry.LookupIn, "first claim, // within org"), RemoteSource.verify's once-per-blob content-address check on // presigning storage, and the quota booking moved into /store/sign. // // Every test asserts the SECURE behavior, so it goes green the moment the hole // is closed and stays as a permanent regression test. Helpers are prefixed // secfx4; no existing file is touched. import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // ---- helpers ------------------------------------------------------------- // secfx4Registry gives a permHub the device registry every ownership decision // in store.go now reads. func secfx4Registry(t *testing.T, srv *Server) *DeviceRegistry { t.Helper() reg, err := OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json")) if err != nil { t.Fatal(err) } srv.Devices = reg return reg } // secfx4Store is one store-API request carrying the device headers // remote/http.go always sends. func secfx4Store(t *testing.T, h http.Handler, method, target string, body string, c *http.Cookie, dev string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, strings.NewReader(body)) if dev != "" { req.Header.Set("X-Bdrive-Device", dev) req.Header.Set("X-Bdrive-Device-Name", "machine-"+dev) req.Header.Set("X-Bdrive-Os", "darwin/arm64") } if c != nil { req.AddCookie(c) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func secfx4PushJournal(t *testing.T, h http.Handler, project, dev, body string, c *http.Cookie) *httptest.ResponseRecorder { t.Helper() return secfx4Store(t, h, "PUT", "/api/p/"+project+"/store/object?key=journal/"+dev+".jsonl", body, c, dev) } func secfx4OpLine(seq int, kind, path, blob string) string { b, _ := json.Marshal(map[string]any{ "seq": seq, "lamport": seq, "time": time.Now().UTC().Format(time.RFC3339Nano), "kind": kind, "path": path, "blob": blob, "size": 1, }) return string(b) + "\n" } func secfx4Sha(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } // --------------------------------------------------------------------------- // ownJournal's new account binding: LookupIn, "first claim, within org". // --------------------------------------------------------------------------- // handleStorePut calls observeDevice(r) BEFORE ownJournal(w, r, key). Observe // creates the (account, id) row from the caller's own header, and LookupIn // returns the row with the earliest FirstSeen. So for an id nobody holds a row // for yet, the request being authorized manufactures the very fact that // authorizes it: bob names alice's device id, becomes its first claimant on // the way in, and passes. // // Round 4's own commit message calls this exact write "the critical": "any // member wrote and replaced any peer's journal: her ops vanish, every device // replays the attacker's deletes, History credits them to her". The header/key // match was replaced by an ownership lookup that the same request can seed. // // The device ids this needs are not secret — History and /heat?by=device // report them to every project member — and the window is every device that // has not yet pushed to THIS hub: a teammate between `bdrive login` and their // first `bdrive init`. func TestSec_Journal_AnUnclaimedDeviceIdIsNotWonByTheWriteItGuards(t *testing.T) { h, srv, c, p := permHub(t) secfx4Registry(t, srv) const aliceDev = "alice-laptop-7c31" // bob is an ordinary member. He has never used this id and neither has // anyone else on this hub yet. body := secfx4OpLine(1, "delete", "quarterly-plan.md", "") rec := secfx4PushJournal(t, h, p.ID, aliceDev, body, c["bob"]) if rec.Code != http.StatusForbidden { t.Errorf("bob wrote journal/%s.jsonl as alice's device: %d %s\n"+ "handleStorePut observes the device from the request's own header before ownJournal "+ "asks who owns it, so LookupIn returns the row this very request just created — "+ "an id with no prior row authorizes whoever names it first", aliceDev, rec.Code, strings.TrimSpace(rec.Body.String())) } } // The same claim, seen from the victim's side: it is a permanent lockout. // Once bob holds the earliest FirstSeen for an id, claimedBefore keeps him // there forever — the row is never re-evaluated, DeviceRepo has no Delete, // DeviceRegistry has no release, no admin route touches it, and the CLI has no // command that re-mints device.json's id. Alice's laptop can never push to // this project again, and the 403 body ("a device may only write its own // journal") names no remedy. // // One store request from any member of the org is the whole attack. func TestSec_Journal_TheRealOwnerStillSyncsAfterSomeoneElseNamedHerId(t *testing.T) { h, srv, c, p := permHub(t) secfx4Registry(t, srv) const aliceDev = "alice-laptop-88ab" // bob names her id once. Even a GET is enough — every /store/* handler // observes the device on the way in. if rec := secfx4Store(t, h, "GET", "/api/p/"+p.ID+"/store/list?prefix=blobs/", "", c["bob"], aliceDev); rec.Code != 200 { t.Fatalf("bob's store call: %d %s", rec.Code, rec.Body) } // Alice's laptop now comes online for the first time and pushes its // journal, exactly as remote/http.go does. rec := secfx4PushJournal(t, h, p.ID, aliceDev, secfx4OpLine(1, "put", "notes.md", secfx4Sha("hi")), c["alice"]) if rec.Code != 200 { t.Errorf("alice cannot sync her own device: %d %s\n"+ "bob's single store request registered (bob, %s) with the earliest FirstSeen, "+ "LookupIn returns it forever, and nothing in the repo, the registry, the API or the CLI "+ "can release a claim — this is an unrecoverable denial of sync inflicted by any org member", rec.Code, strings.TrimSpace(rec.Body.String()), aliceDev) } } // The registry rows are global; the VISIBILITY predicate is per project. // deviceVisibleIn drops every row whose owner is not in the project's org, so // an established owner's row simply disappears from LookupIn the moment she // leaves the org — and the next member to name her id becomes its first // visible claimant. // // Offboarding is the ordinary trigger: remove a teammate (the route owners are // told to use), and her journal in every project of that org becomes writable // by anyone still in it. Her whole contribution history can be replaced with // deletes, and History still credits the ops to her device. func TestSec_Journal_AnOffboardedMembersJournalIsNotUpForGrabs(t *testing.T) { h, srv, c, p := permHub(t) secfx4Registry(t, srv) const carolDev = "carol-desktop-4411" // carol syncs normally: her row is created, first-claimed, and she owns // her journal. if rec := secRegisterDevice(t, h, p.ID, c["carol"], carolDev, "carol-desktop", "linux"); rec.Code != 200 { t.Fatalf("carol's sync: %d %s", rec.Code, rec.Body) } real := secfx4OpLine(1, "put", "carols-report.md", secfx4Sha("carol's report")) if rec := secfx4PushJournal(t, h, p.ID, carolDev, real, c["carol"]); rec.Code != 200 { t.Fatalf("carol's own journal push: %d %s", rec.Code, rec.Body) } // Control: bob cannot touch it while she is a member. if rec := secfx4PushJournal(t, h, p.ID, carolDev, secfx4OpLine(1, "delete", "carols-report.md", ""), c["bob"]); rec.Code != http.StatusForbidden { t.Fatalf("control: bob wrote carol's journal while she was a member: %d %s", rec.Code, rec.Body) } // She leaves the org. if err := srv.Dir.RemoveMember(p.Org, "carol@x.io"); err != nil { t.Fatal(err) } rec := secfx4PushJournal(t, h, p.ID, carolDev, secfx4OpLine(1, "delete", "carols-report.md", ""), c["bob"]) if rec.Code != http.StatusForbidden { t.Errorf("after carol was removed from the org, bob replaced her whole journal: %d %s\n"+ "deviceVisibleIn hides a non-member's row from LookupIn, so the ownership fact "+ "vanishes with the membership and the next caller to name the id is treated as its "+ "first claimant — her ops are gone from every device and History still blames her", rec.Code, strings.TrimSpace(rec.Body.String())) } } // A row whose User is empty turns the whole binding off: ownJournal only // refuses when `info.User != "" && info.User != caller`. Empty-owner rows are // not hypothetical — they are what an upgrade produces. A devices.json written // before the hub had accounts has no `user` and no `first_seen`, and // claimedBefore explicitly sorts a zero FirstSeen OLDEST ("the safe direction: // an established device keeps its identity against a newcomer"), so the // ownerless row is the one LookupIn returns forever. The SQL backend's // migration does the same thing: it copies `devices` rows into `device_rows` // with whatever user_email they had, ” included. // // So on any hub carrying rows from before this field existed, round 4's // critical is open again for exactly those devices — the established ones. func TestSec_Journal_AnOwnerlessLegacyRowDoesNotDisableTheAccountBinding(t *testing.T) { h, srv, c, p := permHub(t) const aliceDev = "alice-laptop-legacy" // A devices.json as an older release wrote it: no user, no first_seen. path := filepath.Join(t.TempDir(), "devices.json") legacy := `{"devices":[{"id":"` + aliceDev + `","name":"Alice's MacBook","os":"darwin","last_seen":"2026-01-02T03:04:05Z"}]}` if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { t.Fatal(err) } reg, err := OpenDeviceRegistry(path) if err != nil { t.Fatal(err) } srv.Devices = reg rec := secfx4PushJournal(t, h, p.ID, aliceDev, secfx4OpLine(1, "delete", "quarterly-plan.md", ""), c["bob"]) if rec.Code != http.StatusForbidden { t.Errorf("bob replaced journal/%s.jsonl on a hub upgraded from before device rows had owners: %d %s\n"+ "ownJournal only refuses when info.User != \"\", and claimedBefore ranks a zero FirstSeen "+ "as the earliest claim — so the pre-accounts row wins LookupIn and asserts no owner, "+ "leaving the binding off for every device that existed before the upgrade", aliceDev, rec.Code, strings.TrimSpace(rec.Body.String())) } } // --------------------------------------------------------------------------- // RemoteSource.verify — "verified once per blob, blobs are immutable". // --------------------------------------------------------------------------- // The premise of the once-per-blob cache is that a blob is immutable. On a // presigning hub it is not: SignPut hands out a URL that writes straight into // the object store and stays valid for the whole TTL (Upload.ttl(), 15 minutes // by default), and an object store accepts every PUT to it, not the first. // // So the sequence is: get a URL, upload the honest bytes, let any read verify // and cache them, then replay the same URL with different bytes. From then on // verify() returns early on the cached answer and the hub serves whatever is // stored under that sha256 — to the viewer, to history, to /store/object (the // peers' fetch path), and to public /s/ share links, which are the reason this // check was added at all. // // Re-uploading is not even necessary to reach the cache: the FIRST read of a // blob is verified, and every read after it is not, so the window is the whole // remaining life of the process. func TestSec_Blob_AVerifiedBlobIsRecheckedWhenTheStoredObjectChanges(t *testing.T) { h, _, p, signer := secsignHub(t) honest := "the version everyone reviewed" sha := secfx4Sha(honest) // The honest upload, through the server, so it really is content-addressed. if rec := secsignReq(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=blobs/"+sha, []byte(honest), nil); rec.Code != 200 { t.Fatalf("honest put: %d %s", rec.Code, rec.Body) } // A read: this is what populates RemoteSource.verified. if rec := secsignReq(t, h, "GET", "/api/p/"+p.ID+"/store/object?key=blobs/"+sha, nil, nil); rec.Code != 200 { t.Fatalf("first read: %d %s", rec.Code, rec.Body) } // The presigned-URL replay: bytes land in the object store without passing // through the hub, which is exactly what "direct-to-storage" means. const evil = "" if err := signer.Put(context.Background(), p.ID+"/blobs/"+sha, strings.NewReader(evil), int64(len(evil))); err != nil { t.Fatal(err) } rec := secsignReq(t, h, "GET", "/api/p/"+p.ID+"/store/object?key=blobs/"+sha, nil, nil) if rec.Code == 200 && rec.Body.String() == evil { t.Errorf("the hub serves content that does not hash to its key:\n"+ " key blobs/%s\n"+ " body %q\n"+ "verify() caches a sha in RemoteSource.verified after ONE read and never looks again, "+ "but a presigned PUT is replayable for its whole TTL — so the check is defeated by "+ "uploading the honest bytes first", sha, rec.Body.String()) } } // The same substituted object, one surface over: /api/p//blob?sha= is the // history version viewer, and it is the surface a reviewer uses to check what // a change actually contained. Asserting it separately keeps a fix that only // patches handleStoreGet from looking green. func TestSec_Blob_HistoryVersionViewIsNotServedFromAStaleVerification(t *testing.T) { h, _, p, signer := secsignHub(t) honest := "approved text" sha := secfx4Sha(honest) if rec := secsignReq(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=blobs/"+sha, []byte(honest), nil); rec.Code != 200 { t.Fatalf("honest put: %d %s", rec.Code, rec.Body) } if rec := secsignReq(t, h, "GET", "/api/p/"+p.ID+"/blob?sha="+sha, nil, nil); rec.Code != 200 { t.Fatalf("first history read: %d %s", rec.Code, rec.Body) } const evil = "not what was approved" if err := signer.Put(context.Background(), p.ID+"/blobs/"+sha, strings.NewReader(evil), int64(len(evil))); err != nil { t.Fatal(err) } if rec := secsignReq(t, h, "GET", "/api/p/"+p.ID+"/blob?sha="+sha, nil, nil); rec.Code == 200 && rec.Body.String() == evil { t.Errorf("history serves a substituted version under the reviewed sha: %q", rec.Body.String()) } } // --------------------------------------------------------------------------- // The quota booking round 4 moved into /store/sign. // --------------------------------------------------------------------------- // "Book it here or never" is a false choice, and the version that shipped // charges for bytes that may never exist. RecordUsage runs the moment a URL is // minted, on a size the caller declared, for an object nobody has to upload — // so any member of an org spends its entire storage allowance with a handful // of JSON POSTs and no bytes at all. On a metered plan that is a bill; on a // capped one it is a write outage for the whole org, and it cannot be undone // because nothing ever refunds a sign that went unused. // // The keys do not even have to be plausible: any 64 hex characters that do not // already exist gets its own signature and its own charge. func TestSec_Sign_QuotaIsOnlyChargedForBytesThatArrive(t *testing.T) { h, srv, p, _ := secsignHub(t) q := &secsignQuota{} srv.Quota = q const gib = 1 << 30 for i := 0; i < 20; i++ { sha := secfx4Sha(fmt.Sprintf("never-uploaded-%d", i)) body, _ := json.Marshal(map[string]any{"key": "blobs/" + sha, "size": int64(gib)}) rec := secsignReq(t, h, "POST", "/api/p/"+p.ID+"/store/sign", body, map[string]string{"Content-Type": "application/json"}) if rec.Code != 200 { t.Fatalf("sign %d: %d %s", i, rec.Code, rec.Body) } } _, recorded := q.totals() if recorded != 0 { t.Errorf("the hub booked %d bytes for uploads that never happened (%d GiB)\n"+ "handleStoreSign calls quota().RecordUsage on the DECLARED size at signing time, "+ "and nothing reconciles it against what storage actually received — one member "+ "exhausts the org's allowance with 20 JSON posts", recorded, recorded/gib) } }