Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions chipper/pkg/initwirepod/startserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
wpweb "github.com/kercre123/wire-pod/chipper/pkg/wirepod/config-ws"
wp "github.com/kercre123/wire-pod/chipper/pkg/wirepod/preqs"
sdkWeb "github.com/kercre123/wire-pod/chipper/pkg/wirepod/sdkapp"
botsetup "github.com/kercre123/wire-pod/chipper/pkg/wirepod/setup"
"github.com/soheilhy/cmux"

// grpclog "github.com/digital-dream-labs/hugh/grpc/interceptors/logger"
Expand Down Expand Up @@ -171,6 +172,17 @@ func StartChipper() {
}
}

// Read-only coherence check: regenerating to fix a mismatch would de-pair
// every robot, so this only reports. Skipped for the unspecified address,
// which is what GetOutboundIP returns with no route -- warning that a good
// certificate fails to cover 0.0.0.0 is noise. ParseIP is nil for a
// hostname and nil.IsUnspecified is false, so hostnames still get checked.
if !vars.APIConfig.Server.EPConfig {
if addr := vars.GetRobotFacingAddress(); !net.ParseIP(addr).IsUnspecified() {
botsetup.WarnIfCertMissesAddress(addr)
}
}

logger.Println("Initiating TLS listener, cmux, gRPC handler, and REST handler")
cert, err := tls.X509KeyPair(certPub, certPriv)
if err != nil {
Expand Down
15 changes: 15 additions & 0 deletions chipper/pkg/initwirepod/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"

"github.com/kercre123/wire-pod/chipper/pkg/logger"
"github.com/kercre123/wire-pod/chipper/pkg/vars"
Expand All @@ -29,6 +30,16 @@ func ChipperHTTPApi(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "error: port is invalid")
return
}
// Parse the proxy fields BEFORE generating anything: the certificate
// bakes in the robot-facing address, so the config must be populated
// first or the cert would cover the wrong name.
robotFacing := strings.TrimSpace(r.FormValue("robot_facing_address"))
if err := vars.ValidateRobotFacingAddress(robotFacing); err != nil {
fmt.Fprint(w, "error: robot-facing address is invalid: "+err.Error())
return
}
vars.APIConfig.Server.ProxyMode = r.FormValue("proxy_mode") == "true"
vars.APIConfig.Server.RobotFacingAddress = robotFacing
vars.APIConfig.Server.EPConfig = false
vars.APIConfig.Server.Port = port
err = botsetup.CreateCertCombo()
Expand All @@ -44,6 +55,10 @@ func ChipperHTTPApi(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "done")
return
case r.URL.Path == "/api-chipper/use_ep":
// proxy_mode is meaningful in EP mode too -- it gates the per-robot
// address UI, which is exactly what a proxied EP install needs. The
// robot-facing address is not: EP mode hardcodes escapepod.local.
vars.APIConfig.Server.ProxyMode = r.FormValue("proxy_mode") == "true"
vars.APIConfig.Server.EPConfig = true
vars.APIConfig.Server.Port = "443"
vars.APIConfig.PastInitialSetup = true
Expand Down
4 changes: 2 additions & 2 deletions chipper/pkg/servers/jdocs/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (s *JdocServer) ReadDocs(ctx context.Context, req *jdocspb.ReadDocsReq) (*j
os.WriteFile(fullPath, tokenserver.SessionWriteStoreCerts[num], 0755)
// export to ./session-certs
os.WriteFile(vars.SessionCertPath+"/"+esn, tokenserver.SessionWriteStoreCerts[num], 0755)
WriteToIniPrimary(pair[1], esn, botGUID, ipAddr)
WriteToIniPrimary(pair[1], esn, botGUID, vars.GetRobotDialTarget(esn, ipAddr))
vars.AddToRInfo(esn, pair[1], ipAddr)
tokenserver.RemoveFromSessionStore(num)
logger.Println("Session certificate successfully output")
Expand All @@ -128,7 +128,7 @@ func (s *JdocServer) ReadDocs(ctx context.Context, req *jdocspb.ReadDocsReq) (*j
// creates apptoken jdoc file
tokenserver.WriteTokenHash(esn, hash)
if !sessionMatched {
WriteToIniSecondary(esn, guid, ipAddr)
WriteToIniSecondary(esn, guid, vars.GetRobotDialTarget(esn, ipAddr))
}
// bot is not authenticated yet, do not write to botinfo json
tokenJdoc, _ := vars.GetJdoc(req.Thing, "vic.AppToken")
Expand Down
124 changes: 124 additions & 0 deletions chipper/pkg/servers/token/requestesn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package tokenserver

import (
"context"
"testing"

"github.com/kercre123/wire-pod/chipper/pkg/vars"
)

// withProxyMode sets server.proxy_mode for the duration of a test. The token
// claim is only preferred behind a proxy.
func withProxyMode(t *testing.T, on bool) {
t.Helper()
restore := vars.APIConfig
t.Cleanup(func() { vars.APIConfig = restore })
vars.APIConfig.Server.ProxyMode = on
}

// Two robots behind one gateway is the case the preference exists for: the peer
// address resolves to whichever is stored first, so without the token claim a
// refresh from one robot is attributed to the other.
func TestProxyModePrefersTheESNTheTokenNames(t *testing.T) {
withProxyMode(t, true)
seedBotInfo(t, sharedPeerBotInfo)
withKnownRobots(t, "00e20145", "00e20146")

for _, want := range []string{"00e20145", "00e20146"} {
got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:"+want)), "172.19.0.1")
if err != nil {
t.Fatalf("resolveTokenRequestESN: unexpected error %v", err)
}
if got != want {
t.Errorf("resolveTokenRequestESN = %q, want %q; behind a proxy the "+
"token is the only thing that tells two robots apart", got, want)
}
}
}

// Without proxy_mode the peer address wins even when a token names someone
// else. This is the guard that matters: preferring the claim also clears the
// error, and that is what routes CreateJWT into the branch which persists
// SetBotGUID and ChangeGUIDInIni. The claim cannot be verified, so ungated it
// would let anyone who knows an ESN rotate that robot's GUID.
func TestWithoutProxyModeThePeerAddressWins(t *testing.T) {
withProxyMode(t, false)
seedBotInfo(t, twoRobotBotInfo)
withKnownRobots(t, "00e20145", "00e20146")

// A request genuinely from .150, carrying a token that claims to be .151's robot.
got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20146")), "192.168.1.150")
if err != nil {
t.Fatalf("resolveTokenRequestESN: unexpected error %v", err)
}
if got != "00e20145" {
t.Errorf("resolveTokenRequestESN = %q, want %q; on a direct LAN a token "+
"claim must not override the address the request actually came from",
got, "00e20145")
}
}

// An unknown peer address must keep returning an error when there is no proxy.
// CreateJWT branches on that error, so swallowing it here would silently start
// persisting GUID changes for requests upstream treated as new robots.
func TestWithoutProxyModeAnUnknownAddressStillErrors(t *testing.T) {
withProxyMode(t, false)
seedBotInfo(t, twoRobotBotInfo)
withKnownRobots(t, "00e20145")

if _, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20145")), "10.0.0.99"); err == nil {
t.Error("an unrecognised peer address must still report an error; CreateJWT " +
"branches on it to decide whether to persist a new GUID")
}
}

// Behind a proxy the address is expected not to resolve, and the token is what
// rescues the request. Clearing the error here is deliberate.
func TestProxyModeResolvesEvenWhenTheAddressDoesNot(t *testing.T) {
withProxyMode(t, true)
seedBotInfo(t, twoRobotBotInfo)
withKnownRobots(t, "00e20145")

got, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00e20145")), "172.19.0.1")
if err != nil {
t.Fatalf("resolveTokenRequestESN: unexpected error %v", err)
}
if got != "00e20145" {
t.Errorf("resolveTokenRequestESN = %q, want %q", got, "00e20145")
}
}

// A token naming a robot wire-pod has never stored must not be trusted even
// behind a proxy: it would otherwise clear the error and invent an identity.
func TestProxyModeIgnoresATokenNamingAnUnknownRobot(t *testing.T) {
withProxyMode(t, true)
seedBotInfo(t, twoRobotBotInfo)
withKnownRobots(t, "00e20145")

if _, err := resolveTokenRequestESN(ctxWithToken(issueJWT(t, "vic:00ffffff")), "10.0.0.99"); err == nil {
t.Error("an unknown ESN in the token must fall through to the address path, " +
"which here cannot resolve either")
}
}

// No token at all is the primary-association case, and must behave exactly as
// it did before any of this.
func TestNoTokenFallsBackToTheAddress(t *testing.T) {
withProxyMode(t, true)
seedBotInfo(t, twoRobotBotInfo)
withKnownRobots(t, "00e20145")

got, err := resolveTokenRequestESN(context.Background(), "192.168.1.150")
if err != nil {
t.Fatalf("resolveTokenRequestESN: unexpected error %v", err)
}
if got != "00e20145" {
t.Errorf("resolveTokenRequestESN = %q, want %q from the peer address", got, "00e20145")
}
}

// Two robots whose stored ip_address is the gateway, which is what wire-pod
// records for every robot behind a proxy.
const sharedPeerBotInfo = `{"global_guid":"g","robots":[` +
`{"esn":"00e20145","ip_address":"172.19.0.1","guid":"g1","activated":true},` +
`{"esn":"00e20146","ip_address":"172.19.0.1","guid":"g2","activated":true}]}`
144 changes: 144 additions & 0 deletions chipper/pkg/servers/token/requestidentity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package tokenserver

import (
"context"
"crypto/rand"
"crypto/rsa"
"testing"

"github.com/golang-jwt/jwt"
"github.com/kercre123/wire-pod/chipper/pkg/vars"
"google.golang.org/grpc/metadata"
)

// issueJWT mints a token the way CreateJWT does: signed with a throwaway key,
// which is exactly why the claims can only ever be parsed unverified.
func issueJWT(t *testing.T, requestorID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS512, jwt.MapClaims{
"requestor_id": requestorID,
"token_type": "user+robot",
"user_id": UserId,
})
key, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("generating key: %v", err)
}
signed, err := token.SignedString(key)
if err != nil {
t.Fatalf("signing: %v", err)
}
return signed
}

func ctxWithToken(jwtToken string) context.Context {
return metadata.NewIncomingContext(context.Background(),
metadata.Pairs("anki-app-key", "oDoa0quieSeir6goowai7f", "anki-access-token", jwtToken))
}

func withKnownRobots(t *testing.T, esns ...string) {
t.Helper()
restore := vars.BotInfo
t.Cleanup(func() { vars.BotInfo = restore })

vars.BotInfo = vars.RobotInfoStore{GlobalGUID: "g"}
for _, esn := range esns {
vars.BotInfo.Robots = append(vars.BotInfo.Robots, struct {
Esn string `json:"esn"`
IPAddress string `json:"ip_address"`
GUID string `json:"guid"`
Activated bool `json:"activated"`
}{Esn: esn, IPAddress: "172.19.0.1", GUID: "guid-" + esn, Activated: true})
}
}

// The point of the change: two robots share a peer address, and the token is
// what tells them apart on a refresh.
func TestESNFromRequestTokenIdentifiesTheRightRobot(t *testing.T) {
withKnownRobots(t, "00e20145", "00e20146")

for _, want := range []string{"00e20145", "00e20146"} {
got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:"+want)))
if got != want {
t.Errorf("esnFromRequestToken = %q, want %q; the token must disambiguate "+
"robots sharing a peer address", got, want)
}
}
}

func TestESNFromRequestTokenReturnsEmptyWithoutMetadata(t *testing.T) {
withKnownRobots(t, "00e20145")

if got := esnFromRequestToken(context.Background()); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\" when there is no metadata", got)
}
empty := metadata.NewIncomingContext(context.Background(), metadata.Pairs("anki-app-key", "x"))
if got := esnFromRequestToken(empty); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\" when no token is present", got)
}
}

// An unparseable token must fall through to the address path, not error out.
func TestESNFromRequestTokenIgnoresGarbage(t *testing.T) {
withKnownRobots(t, "00e20145")

if got := esnFromRequestToken(ctxWithToken("not-a-jwt")); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\" for an unparseable token", got)
}
}

// A token naming a robot we have never stored must not select anything. This
// is what stops the claim from inventing a robot, and it is also what makes
// the pre-association placeholder requestor_id harmless.
func TestESNFromRequestTokenRejectsUnknownRobot(t *testing.T) {
withKnownRobots(t, "00e20145")

if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00ffffff"))); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\" for a robot not in the store", got)
}
// The placeholder wire-pod issues before a robot is associated.
if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00601b50"))); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\" for the pre-association "+
"placeholder requestor_id", got)
}
}

func TestESNFromRequestTokenHandlesMalformedRequestorID(t *testing.T) {
withKnownRobots(t, "00e20145")

for _, requestor := range []string{"", "vic:", " ", "00e20145"} {
got := esnFromRequestToken(ctxWithToken(issueJWT(t, requestor)))
// A bare ESN with no "vic:" prefix is still usable; the others are not.
want := ""
if requestor == "00e20145" {
want = "00e20145"
}
if got != want {
t.Errorf("requestor_id %q: esnFromRequestToken = %q, want %q", requestor, got, want)
}
}
}

func TestESNFromRequestTokenMatchesCaseInsensitively(t *testing.T) {
withKnownRobots(t, "00e20145")

if got := esnFromRequestToken(ctxWithToken(issueJWT(t, "vic:00E20145"))); got != "00e20145" {
t.Errorf("esnFromRequestToken = %q, want the stored casing %q", got, "00e20145")
}
}

// Primary association must be untouched: it carries no usable token, and its
// branch ignores the resolved ESN anyway. This pins that the resolver stays
// silent there rather than relying on the branch to save us.
func TestPrimaryAssociationCarriesNoUsableToken(t *testing.T) {
withKnownRobots(t, "00e20145", "00e20146")

// What queue.go:119-131 actually sends on a primary association.
primary := metadata.NewIncomingContext(context.Background(),
metadata.Pairs("anki-app-key", "oDoa0quieSeir6goowai7f", "anki-user-session", "sess-abc"))

if got := esnFromRequestToken(primary); got != "" {
t.Errorf("esnFromRequestToken = %q, want \"\"; a primary association sends no "+
"access token and must not resolve to any robot", got)
}
}
Loading