carlos / platform Public

relay: key derivation and the credential contract (spec §2, §4)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 3f2283061ced6b002bda83ca4f40a70fdb7a798b parent 5965961
3 files changed, +121 −0
  • internal/relay/keys.go +82 −0
  • internal/relay/keys_test.go +17 −0
  • internal/relay/vector_test.go +22 −0
diff --git a/internal/relay/keys.go b/internal/relay/keys.go
new file mode 100644
index 000000000..6e385edec
--- /dev/null
+++ b/internal/relay/keys.go
@@ -0,0 +1,82 @@
+// Package relay is the platform STUN/TURN relay: the app-facing credential
+// contract (spec §2), key derivation (§4), and the server (§3).
+package relay
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/hkdf"
+)
+
+const (
+ MaxCredentialLife = 4 * time.Hour
+ hkdfSalt = "carlos-relay-v1"
+)
+
+var (
+ accountRe = regexp.MustCompile(`^[a-z0-9]+$`)
+ appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+ incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
+ kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`)
+)
+
+// DeriveKey is HKDF-SHA256(master, salt "carlos-relay-v1",
+// info account||0x00||app||0x00||incarnation, 32).
+func DeriveKey(master []byte, account, app, incarnation string) []byte {
+ info := []byte(account + "\x00" + app + "\x00" + incarnation)
+ r := hkdf.New(sha256.New, master, []byte(hkdfSalt), info)
+ key := make([]byte, 32)
+ if _, err := io.ReadFull(r, key); err != nil {
+ panic("relay: hkdf: " + err.Error()) // cannot fail for 32 bytes
+ }
+ return key
+}
+
+func Suffix(account, app, incarnation, kid string) string {
+ return account + ":" + app + ":" + incarnation + ":" + kid
+}
+
+// Credential is base64(HMAC-SHA256(key, username)), the TURN password.
+func Credential(key []byte, username string) string {
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte(username))
+ return base64.StdEncoding.EncodeToString(h.Sum(nil))
+}
+
+type Username struct {
+ Expiry time.Time
+ Account, App, Incarnation, Kid string
+}
+
+// ParseUsername splits "<expiry>:<account>:<app>:<incarnation>:<kid>" and
+// validates each field's alphabet. It does not check expiry against now;
+// the auth handler does that with its own clock.
+func ParseUsername(s string) (Username, error) {
+ f := strings.Split(s, ":")
+ if len(f) != 5 {
+ return Username{}, fmt.Errorf("relay: username has %d fields, want 5", len(f))
+ }
+ exp, err := strconv.ParseInt(f[0], 10, 64)
+ if err != nil || exp < 0 {
+ return Username{}, fmt.Errorf("relay: bad expiry %q", f[0])
+ }
+ switch {
+ case !accountRe.MatchString(f[1]):
+ return Username{}, fmt.Errorf("relay: bad account %q", f[1])
+ case !appRe.MatchString(f[2]):
+ return Username{}, fmt.Errorf("relay: bad app %q", f[2])
+ case !incarnationRe.MatchString(f[3]):
+ return Username{}, fmt.Errorf("relay: bad incarnation %q", f[3])
+ case !kidRe.MatchString(f[4]):
+ return Username{}, fmt.Errorf("relay: bad kid %q", f[4])
+ }
+ return Username{Expiry: time.Unix(exp, 0).UTC(), Account: f[1], App: f[2], Incarnation: f[3], Kid: f[4]}, nil
+}
diff --git a/internal/relay/keys_test.go b/internal/relay/keys_test.go
new file mode 100644
index 000000000..ab5e1cc3e
--- /dev/null
+++ b/internal/relay/keys_test.go
@@ -0,0 +1,17 @@
+package relay
+
+import "testing"
+
+func TestParseUsername(t *testing.T) {
+ u, err := ParseUsername("1900000000:abc:meet:0123456789abcdef:k1")
+ if err != nil || u.Account != "abc" || u.App != "meet" || u.Incarnation != "0123456789abcdef" || u.Kid != "k1" || u.Expiry.Unix() != 1900000000 {
+ t.Fatalf("parse = %+v %v", u, err)
+ }
+ for _, bad := range []string{"", "1:2:3:4", "1:2:3:4:5:6", "x:abc:meet:0123456789abcdef:k1",
+ "1900000000:ABC:meet:0123456789abcdef:k1", "1900000000:abc:9meet:0123456789abcdef:k1",
+ "1900000000:abc:meet:short:k1", "1900000000:abc:meet:0123456789abcdef:K1"} {
+ if _, err := ParseUsername(bad); err == nil {
+ t.Errorf("ParseUsername(%q) accepted", bad)
+ }
+ }
+}
diff --git a/internal/relay/vector_test.go b/internal/relay/vector_test.go
new file mode 100644
index 000000000..f812cf646
--- /dev/null
+++ b/internal/relay/vector_test.go
@@ -0,0 +1,22 @@
+package relay
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "testing"
+)
+
+func TestVector(t *testing.T) {
+ m := sha256.Sum256([]byte("carlos-relay-test-master"))
+ key := DeriveKey(m[:], "abc", "meet", "0123456789abcdef")
+ if got := base64.StdEncoding.EncodeToString(key); got != "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" {
+ t.Fatalf("key = %s", got)
+ }
+ u := "1900000000:" + Suffix("abc", "meet", "0123456789abcdef", "k1")
+ if u != "1900000000:abc:meet:0123456789abcdef:k1" {
+ t.Fatalf("username = %s", u)
+ }
+ if got := Credential(key, u); got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
+ t.Fatalf("credential = %s", got)
+ }
+}