~samwhited/xmpp

1cbcfdbebc42cb2ef923d6e10448243a0848273b — Sam Whited 4 years ago 0d9967e
Add an experimental IBR2 API

(the implementation is not actually done yet)
5 files changed, 331 insertions(+), 0 deletions(-)

A ibr2/challenge.go
A ibr2/form.go
A ibr2/ibr2.go
A ibr2/ibr2_test.go
A ibr2/oob.go
A ibr2/challenge.go => ibr2/challenge.go +13 -0
@@ 0,0 1,13 @@
// Copyright 2017 Sam Whited.
// Use of this source code is governed by the BSD 2-clause license that can be
// found in the LICENSE file.

package ibr2

// Challenge is an IBR challenge.
// API WARNING: The challenge struct is not complete or usable yet.
type Challenge struct {
	// Type is the type of the challenge as it appears in the server advertised
	// challenges list.
	Type string
}

A ibr2/form.go => ibr2/form.go +24 -0
@@ 0,0 1,24 @@
// Copyright 2017 Sam Whited.
// Use of this source code is governed by the BSD 2-clause license that can be
// found in the LICENSE file.

package ibr2

import (
	"mellium.im/xmpp/form"
)

// Form is a challenge that presents or receives a data form as specified in
// XEP-0004.
// If Form is used by a client, f is called and passed the form sent by the
// server.
// The returned form should be a response to the sent form.
// If Form is used by a server, f is called once with a nil form and should
// return a form to be sent to the client; it is then called again with the
// clients response at which point a nil form can be returned to terminate the
// exchange, or a second form to be sent to the client can be returned.
func Form(f func(data *form.Data) (*form.Data, error)) Challenge {
	return Challenge{
		Type: form.NS,
	}
}

A ibr2/ibr2.go => ibr2/ibr2.go +128 -0
@@ 0,0 1,128 @@
// Copyright 2017 Sam Whited.
// Use of this source code is governed by the BSD 2-clause license that can be
// found in the LICENSE file.

// Package ibr2 implements the Extensible In-Band Registration ProtoXEP.
package ibr2 // import "mellium.im/xmpp/ibr2"

import (
	"context"
	"encoding/xml"
	"errors"
	"io"

	"mellium.im/xmpp"
)

// Namespaces used by IBR.
const (
	NS = "urn:xmpp:register:0"
)

var (
	errNoChallenge = errors.New("No supported challenges were found")
)

func listFunc(challenges ...Challenge) func(context.Context, *xml.Encoder, xml.StartElement) (bool, error) {
	return func(ctx context.Context, e *xml.Encoder, start xml.StartElement) (req bool, err error) {
		if err = e.EncodeToken(start); err != nil {
			return
		}

		// List challenges
		seen := make(map[string]struct{})
		for _, c := range challenges {
			if _, ok := seen[c.Type]; ok {
				continue
			}
			challengeStart := xml.StartElement{
				Name: xml.Name{Local: "challenge"},
			}
			if err = e.EncodeToken(challengeStart); err != nil {
				return
			}
			if err = e.EncodeToken(xml.CharData(c.Type)); err != nil {
				return
			}
			if err = e.EncodeToken(challengeStart.End()); err != nil {
				return
			}
			seen[c.Type] = struct{}{}
		}

		if err = e.EncodeToken(start.End()); err != nil {
			return
		}
		return req, e.Flush()
	}
}

func parseFunc(challenges ...Challenge) func(ctx context.Context, d *xml.Decoder, start *xml.StartElement) (req bool, supported interface{}, err error) {
	return func(ctx context.Context, d *xml.Decoder, start *xml.StartElement) (bool, interface{}, error) {
		// Parse the list of challenge types sent down by the server.
		parsed := struct {
			Challenges []string `xml:"urn:xmpp:register:0 challenge"`
		}{}
		err := d.DecodeElement(&parsed, start)
		if err != nil {
			return false, false, err
		}

		// Dedup the lists of all challenge types supported by us and all challenge
		// types supported by the server.
		m := make(map[string]struct{})
		for _, c := range challenges {
			m[c.Type] = struct{}{}
		}
		for _, c := range parsed.Challenges {
			m[c] = struct{}{}
		}

		// If there are fewer types in the deduped aggregate list than in the
		// challenges we support, then the server list is a subset of the list we
		// support and we're okay to proceed with negotiation.
		return false, len(m) <= len(challenges), nil
	}
}

func negotiateFunc(challenges ...Challenge) func(context.Context, *xmpp.Session, interface{}) (xmpp.SessionState, io.ReadWriter, error) {
	return func(ctx context.Context, session *xmpp.Session, supported interface{}) (mask xmpp.SessionState, rw io.ReadWriter, err error) {
		server := (session.State() & xmpp.Received) == xmpp.Received

		if !server && !supported.(bool) {
			// We don't support some of the challenge types advertised by the server.
			// This is not an error, so don't return one; it just means we shouldn't
			// be negotiating this feature.
			return
		}

		// TODO:
		panic("not yet supported")
	}
}

// Register returns a new xmpp.StreamFeature that can be used to register a new
// account with the server.
func Register(challenges ...Challenge) xmpp.StreamFeature {
	return xmpp.StreamFeature{
		Name:       xml.Name{Local: "register", Space: NS},
		Necessary:  xmpp.Secure,
		Prohibited: xmpp.Authn,
		List:       listFunc(challenges...),
		Parse:      parseFunc(challenges...),
		Negotiate:  negotiateFunc(challenges...),
	}
}

// Recovery returns a new xmpp.StreamFeature that can be used to recover an
// account for which authentication credentials have been lost.
func Recovery(challenges ...Challenge) xmpp.StreamFeature {
	return xmpp.StreamFeature{
		Name:       xml.Name{Local: "recovery", Space: NS},
		Necessary:  xmpp.Secure,
		Prohibited: xmpp.Authn,
		List:       listFunc(challenges...),
		Parse:      parseFunc(challenges...),
		Negotiate:  negotiateFunc(challenges...),
	}
}

A ibr2/ibr2_test.go => ibr2/ibr2_test.go +144 -0
@@ 0,0 1,144 @@
// Copyright 2017 Sam Whited.
// Use of this source code is governed by the BSD 2-clause license that can be
// found in the LICENSE file.

package ibr2

import (
	"bytes"
	"context"
	"encoding/xml"
	"fmt"
	"reflect"
	"testing"
)

// TestList checks that the server listing is generated properly and does not
// repeat challenge types.
func TestList(t *testing.T) {
	b := new(bytes.Buffer)
	d := xml.NewDecoder(b)
	e := xml.NewEncoder(b)
	f := Recovery(
		Challenge{Type: "jabber:x:data"},
		Challenge{Type: "pow"},
		Challenge{Type: "jabber:x:data"})
	_, err := f.List(context.Background(), e, xml.StartElement{Name: xml.Name{Local: "recover"}})
	if err != nil {
		t.Fatalf("List returned error: %v\n", err)
	}
	o := struct {
		XMLName   xml.Name `xml:"recover"`
		Challenge []string `xml:"challenge"`
	}{}
	err = d.Decode(&o)
	if err != nil {
		t.Fatalf("Decoding error: %v\n", err)
	}
	if len(o.Challenge) != 2 {
		t.Fatalf("Expected 2 challenges, got %d", len(o.Challenge))
	}
	if o.Challenge[0] != "jabber:x:data" {
		t.Errorf("Expected first challenge to be jabber:x:data but got %s", o.Challenge[0])
	}
	if o.Challenge[1] != "pow" {
		t.Errorf("Expected second challenge to be pow but got %s", o.Challenge[1])
	}
}

var parseTests = [...]struct {
	Listing    []string
	Challenges []string
	Supported  bool
}{
	0: {
		[]string{"test", "test", "test", "test", "test", "test"},
		[]string{"type", "more", "test"},
		true,
	},
	1: {
		[]string{"test", "test", "test", "test", "test", "test"},
		[]string{"type", "more"},
		false,
	},
	2: {
		[]string{"test", "test"},
		[]string{"type", "more", "test"},
		true,
	},
	3: {
		[]string{"test", "test"},
		[]string{"type", "more", "new", "castle"},
		false,
	},
	4: {
		[]string{"a", "new", "test"},
		[]string{"new", "test", "a"},
		true,
	},
	5: {
		[]string{},
		[]string{"new", "test", "a"},
		true,
	},
	6: {
		[]string{"nope", "never"},
		[]string{},
		false,
	},
	7: {
		[]string{},
		[]string{},
		true,
	},
}

// TestParse checks that clients parse challenge feature listings correctly and
// that they correctly determine if they support all the listed challenge types.
func TestParse(t *testing.T) {
	for i, tc := range parseTests {
		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
			// Create the feature with the named challenges.
			challenges := make([]Challenge, len(tc.Challenges))
			for i, c := range tc.Challenges {
				challenges[i] = Challenge{Type: c}
			}
			r := Register(challenges...)

			// Marshal an XML listing for us to decode.
			b, err := xml.Marshal(struct {
				XMLName    xml.Name `xml:"urn:xmpp:register:0 recovery"`
				Challenges []string `xml:"challenge"`
			}{
				Challenges: tc.Listing,
			})
			if err != nil {
				t.Fatal(err)
			}

			d := xml.NewDecoder(bytes.NewReader(b))
			tok, err := d.Token()
			if err != nil {
				t.Fatal(err)
			}
			start, ok := tok.(xml.StartElement)
			if !ok {
				t.Fatal("Marshaled bad XML; didn't get start element, got %#v", tok)
			}
			req, data, err := r.Parse(context.Background(), d, &start)

			supported, ok := data.(bool)
			switch {
			case req:
				t.Error("Feature parsed as required")
			case err != nil:
				t.Errorf("Unexpected error while parsing feature: %v", err)
			case !ok:
				t.Errorf("Parse returned wrong type data; want=bool, got=%v", reflect.TypeOf(supported))
			case supported != tc.Supported:
				t.Errorf("Parse got mismatched feature support: want=%v, got=%v", tc.Supported, supported)
			}

		})
	}
}

A ibr2/oob.go => ibr2/oob.go +22 -0
@@ 0,0 1,22 @@
// Copyright 2017 Sam Whited.
// Use of this source code is governed by the BSD 2-clause license that can be
// found in the LICENSE file.

package ibr2

import (
	"mellium.im/xmpp/oob"
)

// OOB is a challenge that must be completed out of band using a URI provided by
// XEP-0066: Out of Band Data.
// If you are a client, f will be called and passed the parsed OOB data.
// If f returns an error, the client considers the negotiation a failure.
// The returned OOB data is ignored for clients.
// For servers, f is also called, but its argument should be ignored and the
// returned OOB data should be sent on the connection (error is also checked).
func OOB(f func(*oob.Data) (*oob.Data, error)) Challenge {
	return Challenge{
		Type: oob.NS,
	}
}