mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
386 lines
18 KiB
Go
386 lines
18 KiB
Go
package webapp
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Round 11 attacks round 10's device-binding rewrite: DeviceRegistry.Bind,
|
||
|
|
// BuiltinAuth.finishLogin, and the deleted `!known && journalNames(dev, ops)`
|
||
|
|
// arm in ownJournal.
|
||
|
|
//
|
||
|
|
// Helper prefix: secfx10.
|
||
|
|
|
||
|
|
// secfx10Project makes a project for `who` and returns its id. A plain member
|
||
|
|
// who creates a project is its ADMIN (TestCreatorBecomesAdmin), which is the
|
||
|
|
// only permission these attacks need.
|
||
|
|
func secfx10Project(t *testing.T, h http.Handler, c *http.Cookie, org, name string) string {
|
||
|
|
t.Helper()
|
||
|
|
body := map[string]any{"name": name}
|
||
|
|
if org != "" {
|
||
|
|
body["org"] = org
|
||
|
|
}
|
||
|
|
rec := doAs(t, h, "POST", "/api/projects", body, c)
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Fatalf("create project %q: %d %s", name, rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
var out struct {
|
||
|
|
Project Project `json:"project"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return out.Project.ID
|
||
|
|
}
|
||
|
|
|
||
|
|
// secfx10Bearer sends a store request authenticated by a DEVICE TOKEN (the
|
||
|
|
// credential a daemon actually holds), not a browser cookie.
|
||
|
|
func secfx10Bearer(t *testing.T, h http.Handler, method, target, body, token, dev string) *httptest.ResponseRecorder {
|
||
|
|
t.Helper()
|
||
|
|
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
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")
|
||
|
|
}
|
||
|
|
rec := httptest.NewRecorder()
|
||
|
|
h.ServeHTTP(rec, req)
|
||
|
|
return rec
|
||
|
|
}
|
||
|
|
|
||
|
|
// secfx10Token pulls the device token out of a successful sign-in response.
|
||
|
|
func secfx10Token(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||
|
|
t.Helper()
|
||
|
|
var out struct {
|
||
|
|
Token string `json:"token"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
|
|
t.Fatalf("sign-in response is not JSON: %s", rec.Body)
|
||
|
|
}
|
||
|
|
if out.Token == "" {
|
||
|
|
t.Fatalf("sign-in handed back no token: %s", rec.Body)
|
||
|
|
}
|
||
|
|
return out.Token
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 1. ownJournal's admin RECOVERY arm creates a competing ownership row, and one
|
||
|
|
// competing row is enough to refuse the real owner's next sign-in forever.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_ARecoveryPushDoesNotLockTheOwnerOutOfSigningIn.
|
||
|
|
//
|
||
|
|
// Round 10 made a device identity a hub-wide binding created at token issuance,
|
||
|
|
// and made a login that cannot bind a 409 — "a credential is never handed to a
|
||
|
|
// machine that cannot then use it". Two rules meet badly:
|
||
|
|
//
|
||
|
|
// - Bind refuses when ANY row for the id names a different account.
|
||
|
|
// - handleStorePut calls observeDevice AFTER ownJournal passes, and one arm
|
||
|
|
// of ownJournal is "project admin is the recovery path". So an admin's
|
||
|
|
// journal PUT for somebody else's device id creates a row keyed
|
||
|
|
// {the admin's email, that id} — a second, competing row.
|
||
|
|
//
|
||
|
|
// Anyone who can create a project is an admin of it (TestCreatorBecomesAdmin),
|
||
|
|
// and ownJournal asks projectPerm on the project in the URL — so the attacker
|
||
|
|
// supplies the project too. dave is in a DIFFERENT ORG from bob and shares
|
||
|
|
// nothing with him.
|
||
|
|
//
|
||
|
|
// After one PUT into dave's own project, bob's device — still the OwnerOf
|
||
|
|
// answer, still able to push — can never sign in again: every `bdrive login`
|
||
|
|
// on that machine answers 409 "already registered to another account". The
|
||
|
|
// documented remedy for a lost binding IS `bdrive login`, so the recovery path
|
||
|
|
// bricks the recovery path, and it does so across the org wall.
|
||
|
|
func TestSec_Device_ARecoveryPushDoesNotLockTheOwnerOutOfSigningIn(t *testing.T) {
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
const bobDev = "bob-laptop-77"
|
||
|
|
|
||
|
|
// Control 1: bob's machine signs in and binds its id.
|
||
|
|
if rec := secRegisterDevice(t, h, p.ID, c["bob"], bobDev, "bobs-box", "darwin"); rec.Code != 200 {
|
||
|
|
t.Fatalf("control: bob's first sign-in: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
// Control 2: signing in again from the same machine is an ordinary thing to
|
||
|
|
// do (a new token, `bdrive init` in a second folder, a re-login after
|
||
|
|
// logout) and it works.
|
||
|
|
if rec := secRegisterDevice(t, h, p.ID, c["bob"], bobDev, "bobs-box", "darwin"); rec.Code != 200 {
|
||
|
|
t.Fatalf("control: bob signs in twice from the same machine: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
if owner, known := srv.Devices.OwnerOf(bobDev); !known || owner != "bob@x.io" {
|
||
|
|
t.Fatalf("fixture: OwnerOf(%s) = %q,%v, want bob@x.io", bobDev, owner, known)
|
||
|
|
}
|
||
|
|
|
||
|
|
// dave is in another org. He makes his own project, where he is admin.
|
||
|
|
dp := secfx10Project(t, h, c["dave"], "", "daves-wiki")
|
||
|
|
|
||
|
|
// One journal PUT naming bob's device id, into dave's own project. The
|
||
|
|
// admin arm lets it through; observeDevice then records {dave, bobDev}.
|
||
|
|
body := secaudOpLine(1, bobDev, "put", "notes.md", strings.Repeat("b", 64))
|
||
|
|
push := secfx4Store(t, h, "PUT",
|
||
|
|
"/api/p/"+dp+"/store/object?key=journal/"+bobDev+".jsonl", body, c["dave"], bobDev)
|
||
|
|
t.Logf("dave's PUT of journal/%s.jsonl into his own project: %d %s",
|
||
|
|
bobDev, push.Code, strings.TrimSpace(push.Body.String()))
|
||
|
|
|
||
|
|
// The property under attack: bob's machine can still sign in. Nothing an
|
||
|
|
// account outside bob's org does may take that away.
|
||
|
|
rec := secRegisterDevice(t, h, p.ID, c["bob"], bobDev, "bobs-box", "darwin")
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Errorf("bob's machine can no longer sign in: %d %s\n"+
|
||
|
|
"one journal PUT from dave — a stranger in another org, into a project of his own — "+
|
||
|
|
"made observeDevice write a second row for bob's device id, and Bind refuses any id "+
|
||
|
|
"that has a row under another account. bob is still OwnerOf and can still push, but "+
|
||
|
|
"`bdrive login` on his own machine is refused forever, which is the documented remedy "+
|
||
|
|
"for every other device problem.",
|
||
|
|
rec.Code, strings.TrimSpace(rec.Body.String()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 2. The 409 is a hub-wide device-existence oracle, across the org wall.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_LoginIsNotAHubWideDeviceExistenceOracle.
|
||
|
|
//
|
||
|
|
// This is the class round 3 closed for History
|
||
|
|
// (TestSec_Journal_IsNotAnExistenceOracle), round 4 re-closed for the registry
|
||
|
|
// join, and round 5 closed for /store/sign
|
||
|
|
// (TestSec_Journal_OwnershipIsNotAHubWideDeviceExistenceOracle): OwnerOf is
|
||
|
|
// deliberately hub-wide, so any route that turns its answer into a status code
|
||
|
|
// answers questions about tenants the caller cannot see through any other
|
||
|
|
// surface.
|
||
|
|
//
|
||
|
|
// finishLogin is a new such route. Bind's refusal is 409 and its BODY says
|
||
|
|
// "device id X is already registered to another account on this hub". bob, a
|
||
|
|
// plain member of alice's org, asks about a device belonging to dave's org —
|
||
|
|
// a separate tenant whose projects, members and devices he cannot reach — and
|
||
|
|
// the sign-in answers, in words.
|
||
|
|
//
|
||
|
|
// The fix does not have to keep the 409: the push door already refuses an id
|
||
|
|
// owned by somebody else and an id owned by nobody with the SAME 403 and the
|
||
|
|
// same message, so a login that binds nothing and hands back the token leaks
|
||
|
|
// nothing and loses nothing.
|
||
|
|
func TestSec_Device_LoginIsNotAHubWideDeviceExistenceOracle(t *testing.T) {
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
|
||
|
|
// dave is in a different org, with his own project, and his device signs in
|
||
|
|
// the ordinary way.
|
||
|
|
dp := secfx10Project(t, h, c["dave"], "", "daves-wiki")
|
||
|
|
const daveDev = "dave-laptop-9f21"
|
||
|
|
if rec := secRegisterDevice(t, h, dp, c["dave"], daveDev, "daves-box", "linux"); rec.Code != 200 {
|
||
|
|
t.Fatalf("dave's sign-in: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
if owner, known := srv.Devices.OwnerOf(daveDev); !known || owner != "dave@x.io" {
|
||
|
|
t.Fatalf("fixture: OwnerOf(%s) = %q,%v, want dave@x.io", daveDev, owner, known)
|
||
|
|
}
|
||
|
|
|
||
|
|
// bob has no way to learn anything about dave's org through any other route.
|
||
|
|
probeUnknown := secRegisterDevice(t, h, p.ID, c["bob"], "no-such-device-0001", "probe", "linux")
|
||
|
|
probeKnown := secRegisterDevice(t, h, p.ID, c["bob"], daveDev, "probe", "linux")
|
||
|
|
|
||
|
|
// Control: the probe is a request bob is allowed to make at all.
|
||
|
|
if probeUnknown.Code != 200 {
|
||
|
|
t.Fatalf("control failed: bob cannot sign in at all: %d %s", probeUnknown.Code, probeUnknown.Body)
|
||
|
|
}
|
||
|
|
if probeKnown.Code != probeUnknown.Code {
|
||
|
|
t.Errorf("a device id owned by another ORG answers %d %q while an id nothing on this hub "+
|
||
|
|
"has ever seen answers %d %q — one `bdrive login` is a hub-wide device-existence "+
|
||
|
|
"oracle across the org wall, and the 409 body names the id back to the caller",
|
||
|
|
probeKnown.Code, strings.TrimSpace(probeKnown.Body.String()),
|
||
|
|
probeUnknown.Code, strings.TrimSpace(probeUnknown.Body.String()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 2b. The hub-side twin of round 10's critical: ownership is decided on an
|
||
|
|
// exact-case id, and the object store folds case.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_ADeviceIdSpelledInAnotherCaseIsNotASecondJournal.
|
||
|
|
//
|
||
|
|
// Round 10's critical (TestSec_HostileHub_CannotOverwriteThisDevicesOwnJournal)
|
||
|
|
// was that syncer.pull skipped a listed journal only on an exact string
|
||
|
|
// compare, so on a case-insensitive filesystem journal/DEVA.jsonl and
|
||
|
|
// journal/deva.jsonl are two keys and ONE FILE. The fix was made on the device.
|
||
|
|
//
|
||
|
|
// The hub has the same shape and did not get the same fix. Every ownership
|
||
|
|
// decision here is exact-case:
|
||
|
|
//
|
||
|
|
// DeviceRegistry.Bind `if k.ID != d.ID` (byte compare)
|
||
|
|
// DeviceRegistry.OwnerOf`if k.ID != id` (byte compare)
|
||
|
|
// ownJournal `key != "journal/"+dev+".jsonl"`
|
||
|
|
//
|
||
|
|
// so "DEVA" is simply a different id from "deva": nobody owns it, any account
|
||
|
|
// may bind it at login, and ownJournal then agrees the caller owns that
|
||
|
|
// journal. The object store underneath does not agree. `bdrive serve` with a
|
||
|
|
// `file://` store on macOS (APFS) or Windows (NTFS) — the default on both, and
|
||
|
|
// what every self-hoster gets from the quickstart — writes both keys to one
|
||
|
|
// file.
|
||
|
|
//
|
||
|
|
// Result: one login and one PUT and a plain member has REPLACED another
|
||
|
|
// account's journal on the hub. That is the one-writer invariant the whole
|
||
|
|
// concurrency design rests on, broken at the hub, with no hostile hub and no
|
||
|
|
// filesystem trick beyond the filesystem the hub is already on.
|
||
|
|
func TestSec_Device_ADeviceIdSpelledInAnotherCaseIsNotASecondJournal(t *testing.T) {
|
||
|
|
h, _, c, p := permHub(t)
|
||
|
|
const aliceDev = "deva9f21"
|
||
|
|
const shouted = "DEVA9F21"
|
||
|
|
|
||
|
|
if rec := secRegisterDevice(t, h, p.ID, c["alice"], aliceDev, "alices-box", "darwin"); rec.Code != 200 {
|
||
|
|
t.Fatalf("alice's sign-in: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
mine := secaudOpLine(1, aliceDev, "put", "quarterly-plan.md", strings.Repeat("a", 64))
|
||
|
|
if rec := secfx4Store(t, h, "PUT",
|
||
|
|
"/api/p/"+p.ID+"/store/object?key=journal/"+aliceDev+".jsonl", mine, c["alice"], aliceDev); rec.Code != 200 {
|
||
|
|
t.Fatalf("control: alice writes her own journal: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// bob is a plain member. He signs in naming the same id in another case.
|
||
|
|
bind := secRegisterDevice(t, h, p.ID, c["bob"], shouted, "bobs-box", "linux")
|
||
|
|
t.Logf("bob's sign-in naming %q (alice owns %q): %d", shouted, aliceDev, bind.Code)
|
||
|
|
forged := secaudOpLine(1, shouted, "delete", "quarterly-plan.md", "")
|
||
|
|
push := secfx4Store(t, h, "PUT",
|
||
|
|
"/api/p/"+p.ID+"/store/object?key=journal/"+shouted+".jsonl", forged, c["bob"], shouted)
|
||
|
|
t.Logf("bob's PUT of journal/%s.jsonl: %d %s", shouted, push.Code, strings.TrimSpace(push.Body.String()))
|
||
|
|
|
||
|
|
// Whatever the hub decided about ownership, alice's journal object must
|
||
|
|
// still hold alice's ops.
|
||
|
|
got := secfx4Store(t, h, "GET",
|
||
|
|
"/api/p/"+p.ID+"/store/object?key=journal/"+aliceDev+".jsonl", "", c["alice"], aliceDev)
|
||
|
|
if got.Code != 200 {
|
||
|
|
t.Fatalf("alice can no longer read her own journal: %d %s", got.Code, got.Body)
|
||
|
|
}
|
||
|
|
if !strings.Contains(got.Body.String(), `"quarterly-plan.md"`) ||
|
||
|
|
!strings.Contains(got.Body.String(), `"put"`) {
|
||
|
|
t.Errorf("bob replaced alice's journal object by spelling her device id %q as %q: "+
|
||
|
|
"journal/%s.jsonl now reads %q.\n"+
|
||
|
|
"Nothing on the hub compares a device id case-insensitively (Bind, OwnerOf and "+
|
||
|
|
"ownJournal are all byte compares) while the store underneath folds case — which is "+
|
||
|
|
"round 10's device-side critical with the hub holding the lever.",
|
||
|
|
aliceDev, shouted, aliceDev, strings.TrimSpace(got.Body.String()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 3. The CISO declined an automatic self-heal (bind on any device-token
|
||
|
|
// request) because it would reopen round 7's hole one credential class over.
|
||
|
|
// This measures that no such path exists.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_ADeviceTokenCannotBindAnUnownedId asserts the decision holds:
|
||
|
|
// a member holding a valid device token, hitting every /store/* door under an
|
||
|
|
// id nothing has ever bound, creates no ownership row and gains no write.
|
||
|
|
func TestSec_Device_ADeviceTokenCannotBindAnUnownedId(t *testing.T) {
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
|
||
|
|
// bob's real machine signs in; he now holds a genuine device token.
|
||
|
|
rec := secRegisterDevice(t, h, p.ID, c["bob"], "bob-real-01", "bobs-box", "darwin")
|
||
|
|
if rec.Code != 200 {
|
||
|
|
t.Fatalf("bob's sign-in: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
token := secfx10Token(t, rec)
|
||
|
|
|
||
|
|
// He now points that credential at an id nobody has bound.
|
||
|
|
const unowned = "never-bound-4242"
|
||
|
|
base := "/api/p/" + p.ID + "/store/"
|
||
|
|
for _, probe := range []struct{ method, target, body string }{
|
||
|
|
{"GET", base + "list?prefix=journal/", ""},
|
||
|
|
{"GET", base + "object?key=journal/" + unowned + ".jsonl", ""},
|
||
|
|
{"GET", base + "exists?key=journal/" + unowned + ".jsonl", ""},
|
||
|
|
{"POST", base + "sign?key=journal/" + unowned + ".jsonl&size=1", ""},
|
||
|
|
} {
|
||
|
|
r := secfx10Bearer(t, h, probe.method, probe.target, probe.body, token, unowned)
|
||
|
|
t.Logf("%s %s -> %d", probe.method, probe.target, r.Code)
|
||
|
|
}
|
||
|
|
if owner, known := srv.Devices.OwnerOf(unowned); known || owner != "" {
|
||
|
|
t.Errorf("a device token reached a binding through a read door: OwnerOf(%s) = %q,%v — "+
|
||
|
|
"round 7's property is that a read door creates nothing", unowned, owner, known)
|
||
|
|
}
|
||
|
|
|
||
|
|
// And the write door still refuses it, so there is no self-heal there either.
|
||
|
|
body := secaudOpLine(1, unowned, "put", "x.md", strings.Repeat("c", 64))
|
||
|
|
push := secfx4Store(t, h, "PUT",
|
||
|
|
"/api/p/"+p.ID+"/store/object?key=journal/"+unowned+".jsonl", body, c["bob"], unowned)
|
||
|
|
if push.Code != http.StatusForbidden {
|
||
|
|
t.Errorf("an unowned id is writable without a binding: %d %s", push.Code, push.Body)
|
||
|
|
}
|
||
|
|
if owner, known := srv.Devices.OwnerOf(unowned); known || owner != "" {
|
||
|
|
t.Errorf("the refused write registered the id it refused: OwnerOf(%s) = %q,%v", unowned, owner, known)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 4. Two logins racing for one id.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_TwoLoginsRacingForOneIdProduceExactlyOneOwner runs the claim
|
||
|
|
// under -race: Bind's check and write are one critical section on purpose, so
|
||
|
|
// two accounts naming the same fresh id must not both believe they won.
|
||
|
|
func TestSec_Device_TwoLoginsRacingForOneIdProduceExactlyOneOwner(t *testing.T) {
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
const contested = "contested-id-01"
|
||
|
|
|
||
|
|
var wg sync.WaitGroup
|
||
|
|
codes := make([]int, 2)
|
||
|
|
who := []string{"bob", "carol"}
|
||
|
|
for i := range who {
|
||
|
|
wg.Add(1)
|
||
|
|
go func(i int) {
|
||
|
|
defer wg.Done()
|
||
|
|
codes[i] = secRegisterDevice(t, h, p.ID, c[who[i]], contested, "box", "linux").Code
|
||
|
|
}(i)
|
||
|
|
}
|
||
|
|
wg.Wait()
|
||
|
|
|
||
|
|
owner, known := srv.Devices.OwnerOf(contested)
|
||
|
|
if !known {
|
||
|
|
t.Fatalf("neither login bound the id: codes=%v", codes)
|
||
|
|
}
|
||
|
|
winners := 0
|
||
|
|
for i, code := range codes {
|
||
|
|
if code == 200 && who[i]+"@x.io" == owner {
|
||
|
|
winners++
|
||
|
|
}
|
||
|
|
if code == 200 && who[i]+"@x.io" != owner {
|
||
|
|
t.Errorf("%s's login returned 200 for %s but OwnerOf says %q — a login that was told "+
|
||
|
|
"it succeeded holds a token its device cannot push with", who[i], contested, owner)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if winners != 1 {
|
||
|
|
t.Errorf("codes=%v owner=%q: exactly one racing login may win the id", codes, owner)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 5. A refused bind must leave nothing behind.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// TestSec_Device_ARefusedBindIssuesNoTokenAndRecordsNothing checks the other
|
||
|
|
// half of "before the token, not after": the 409 must not hand back a
|
||
|
|
// credential, and it must not record the caller against the id it refused —
|
||
|
|
// which is how round 2's ownsDevice turned out to be a one-request speed bump.
|
||
|
|
func TestSec_Device_ARefusedBindIssuesNoTokenAndRecordsNothing(t *testing.T) {
|
||
|
|
h, srv, c, p := permHub(t)
|
||
|
|
const carolDev = "carol-laptop-31"
|
||
|
|
|
||
|
|
if rec := secRegisterDevice(t, h, p.ID, c["carol"], carolDev, "carols-box", "linux"); rec.Code != 200 {
|
||
|
|
t.Fatalf("carol's sign-in: %d %s", rec.Code, rec.Body)
|
||
|
|
}
|
||
|
|
rec := secRegisterDevice(t, h, p.ID, c["bob"], carolDev, "bobs-box", "linux")
|
||
|
|
if rec.Code == 200 {
|
||
|
|
t.Fatalf("bob took carol's bound device id at login: %s", rec.Body)
|
||
|
|
}
|
||
|
|
if strings.Contains(rec.Body.String(), `"token"`) {
|
||
|
|
t.Errorf("a refused sign-in still handed back a credential: %s", rec.Body)
|
||
|
|
}
|
||
|
|
if owner, _ := srv.Devices.OwnerOf(carolDev); owner != "carol@x.io" {
|
||
|
|
t.Errorf("the refused sign-in changed ownership of %s to %q", carolDev, owner)
|
||
|
|
}
|
||
|
|
// And the refusal is not a speed bump: a second attempt is refused too.
|
||
|
|
if again := secRegisterDevice(t, h, p.ID, c["bob"], carolDev, "bobs-box", "linux"); again.Code == 200 {
|
||
|
|
t.Errorf("the second attempt succeeded — the refused request registered what it refused: %s", again.Body)
|
||
|
|
}
|
||
|
|
}
|