The person ref: opaque, minted, never derived from an address
§13 step 2, Task 3. The subject of every session, credential and recovery code, and later the assertion's sub and the directory's join key. 16 bytes of crypto/rand, unpadded base32, lowercased, prefixed pr_. NewPersonRef takes no argument and there is no variant that does. A ref derived from the address — even hashed, even peppered — puts the address back at rest in a reversible-by-dictionary form under another name, which is the thing §3 exists to prevent. A test asserts the signature, because a future signature change is exactly how that would arrive. Base32 rather than base64 because its alphabet has no /, + or =. The ref goes into URL paths and log lines by design (§3.4: "Errors carry person refs and codes"), and a value that needs escaping in either is a value somebody will forget to escape. A test asserts the character set. The validator's expected length is computed from the entropy constant rather than written as 26, so changing one cannot leave the other silently checking the old shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4uVkB6xnZJYuxTooPiUn4
2 files changed,
+154
−0
internal/home/personref.go+74 −0internal/home/personref_test.go+80 −0
diff --git a/internal/home/personref.go b/internal/home/personref.go| new file mode 100644 |
| index 0000000..7091e99 |
| --- /dev/null |
| +++ b/internal/home/personref.go |
| @@ -0,0 +1,74 @@ |
| +package home |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/base32" |
| + "strings" |
| +) |
| + |
| +// The person ref is the subject of every session, credential and |
| +// recovery code, and later the assertion's sub and the directory's join |
| +// key. It is opaque, stable across an email change, and never derived |
| +// from the address. |
| +// |
| +// "Never derived" is the load-bearing word. A ref computed from the |
| +// address — even hashed, even peppered — puts the address back at rest |
| +// in a reversible-by-dictionary form under another name, which is the |
| +// whole thing §3 exists to prevent. NewPersonRef therefore takes no |
| +// argument, and there is no variant that does. |
| +const ( |
| + // personRefBytes is the entropy behind a ref. 16 bytes makes a |
| + // collision implausible at any number of people this will ever hold, |
| + // and stays short enough to read in a log line — which it is allowed |
| + // to appear in, per §3.4: "Errors carry person refs and codes." |
| + personRefBytes = 16 |
| + |
| + // personRefPrefix makes a ref self-describing in a log or an error, |
| + // so a value that turns up somewhere unexpected can be recognised |
| + // for what it is rather than guessed at. |
| + personRefPrefix = "pr_" |
| +) |
| + |
| +// personRefEnc is unpadded base32, lowercased on the way out. Base32 |
| +// rather than base64 because the alphabet has no /, + or =: the ref goes |
| +// into URL paths and log lines, and a value that needs escaping in |
| +// either is a value somebody will forget to escape. |
| +var personRefEnc = base32.StdEncoding.WithPadding(base32.NoPadding) |
| + |
| +// personRefBodyLen is how many characters personRefBytes encodes to. |
| +// Computed rather than written as 26, so changing the entropy cannot |
| +// leave the validator silently checking the old length. |
| +var personRefBodyLen = personRefEnc.EncodedLen(personRefBytes) |
| + |
| +// NewPersonRef mints a fresh, opaque person ref. |
| +func NewPersonRef() (string, error) { |
| + b := make([]byte, personRefBytes) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", err |
| + } |
| + return personRefPrefix + strings.ToLower(personRefEnc.EncodeToString(b)), nil |
| +} |
| + |
| +// ValidPersonRef reports whether s has the exact shape NewPersonRef |
| +// produces: the prefix, the exact length, and nothing outside the |
| +// lowercased base32 alphabet. |
| +// |
| +// It is deliberately a shape check and not a lookup. Callers use it to |
| +// refuse rubbish at the edge — a path segment, a form field — before it |
| +// reaches a query; whether a well-shaped ref names anybody is the |
| +// database's answer, not this function's. |
| +func ValidPersonRef(s string) bool { |
| + body, ok := strings.CutPrefix(s, personRefPrefix) |
| + if !ok || len(body) != personRefBodyLen { |
| + return false |
| + } |
| + for _, r := range body { |
| + switch { |
| + case r >= 'a' && r <= 'z': |
| + case r >= '2' && r <= '7': |
| + default: |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/home/personref_test.go b/internal/home/personref_test.go| new file mode 100644 |
| index 0000000..2475072 |
| --- /dev/null |
| +++ b/internal/home/personref_test.go |
| @@ -0,0 +1,80 @@ |
| +package home |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestPersonRefIsOpaqueAndUnique(t *testing.T) { |
| + seen := map[string]bool{} |
| + for i := 0; i < 1000; i++ { |
| + ref, err := NewPersonRef() |
| + if err != nil { |
| + t.Fatalf("NewPersonRef: %v", err) |
| + } |
| + if seen[ref] { |
| + t.Fatalf("duplicate person ref %q after %d draws", ref, i) |
| + } |
| + seen[ref] = true |
| + if !ValidPersonRef(ref) { |
| + t.Fatalf("NewPersonRef produced %q, which ValidPersonRef rejects", ref) |
| + } |
| + } |
| +} |
| + |
| +func TestPersonRefDoesNotEmbedInput(t *testing.T) { |
| + ref, err := NewPersonRef() |
| + if err != nil { |
| + t.Fatalf("NewPersonRef: %v", err) |
| + } |
| + if strings.Contains(ref, "@") { |
| + t.Fatalf("person ref %q contains an @", ref) |
| + } |
| +} |
| + |
| +// NewPersonRef takes no argument, and that is the point rather than an |
| +// oversight: a ref derived from the address would put the address back |
| +// at rest in a reversible form under another name. This test is the |
| +// reminder, since a future signature change is exactly how that would |
| +// arrive. |
| +func TestPersonRefTakesNoInput(t *testing.T) { |
| + var f func() (string, error) = NewPersonRef |
| + if f == nil { |
| + t.Fatal("NewPersonRef does not have the signature func() (string, error)") |
| + } |
| +} |
| + |
| +func TestValidPersonRefRejectsRubbish(t *testing.T) { |
| + for _, bad := range []string{ |
| + "", |
| + "person@example.invalid", |
| + "../etc", |
| + strings.Repeat("a", 500), |
| + "pr_", // prefix only |
| + "pr_" + strings.Repeat("a", 25), // one short |
| + "pr_" + strings.Repeat("a", 27), // one long |
| + strings.Repeat("a", 26), // no prefix |
| + "PR_" + strings.Repeat("a", 26), // wrong-case prefix |
| + "pr_" + strings.Repeat("A", 26), // wrong-case body |
| + "pr_" + strings.Repeat("1", 26), // 1 is not in base32's alphabet |
| + "pr_" + strings.Repeat("a", 25) + "=", // padding |
| + "pr_" + strings.Repeat("a", 25) + "/", // path separator |
| + "pr_" + strings.Repeat("a", 25) + "\n", // a log-line break |
| + } { |
| + if ValidPersonRef(bad) { |
| + t.Errorf("ValidPersonRef(%q) = true", bad) |
| + } |
| + } |
| +} |
| + |
| +// The ref appears in log lines by design — §3.4: "Errors carry person |
| +// refs and codes" — so it must be safe there and in a URL path. |
| +func TestPersonRefIsSafeInLogsAndPaths(t *testing.T) { |
| + ref, err := NewPersonRef() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if strings.ContainsAny(ref, " \t\r\n\"'/\\?#&=%") { |
| + t.Fatalf("person ref %q contains a character that needs escaping", ref) |
| + } |
| +} |