Files
base/crdt/privacy_security_test.go
zeekayandHanzo Dev 63058856e8 a stream is opened with a grant, because a stream cannot carry a header
EventSource sends no headers, so the request that opens a stream arrives with
no credential and lands on the process's Base, while the request naming that
client carries one and lands on the caller's. The subscription then names a
client the broker has never heard of.

An authenticated request mints a grant and the stream spends it: once, on one
Base, within thirty seconds. The string stands for nothing on its own — what
it means is held here, as the Base the minting request resolved to and the
identity it resolved as, so a stream opens with exactly the authority of the
request that asked for it.

Not the caller's own token in the address. That token opens every service in
the estate for as long as it lives, and an address is written down by things
outside this process — an ingress, a gateway, a cache — where this one can
redact nothing. A grant is worth one stream for half a minute.

The rule is bound at the address a grant opens, so what one reaches is a
property of the route rather than of a string travelling in a URL. It sorts
after the header path, so it never competes with a credential that can travel
in one.

The socket surface beside it is deleted. Nothing referenced it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 17:09:34 -07:00

294 lines
8.6 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package crdt
import (
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"github.com/luxfi/age"
)
// A document configured with age privacy accepts only envelopes whose tag says
// so. These tests pin that: a peer offering a plaintext-tagged envelope to such
// a document is refused rather than resolved.
func TestTagSpoofPlaintextOnAgeDoc(t *testing.T) {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("generate identity: %v", err)
}
priv, err := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
if err != nil {
t.Fatalf("NewAgePrivacy: %v", err)
}
doc := NewDocument("doc", "n", WithPrivacy(priv))
// Attacker sends envelope tagged plaintext/v1 with JSON payload.
fakeEnv := OpEnvelope{
PrivacyTag: "plaintext/v1",
Ciphertext: []byte(`{"field":"body","fieldType":0,"data":"AQID"}`),
}
_, err = doc.OpenOps([]OpEnvelope{fakeEnv})
if err == nil {
t.Fatal("VULNERABLE: plaintext-tagged envelope accepted on age doc")
}
if !errors.Is(err, ErrPrivacyMismatch) {
t.Fatalf("expected ErrPrivacyMismatch, got: %v", err)
}
}
func TestWrongKeySameTag(t *testing.T) {
idA, _ := age.GenerateX25519Identity()
idB, _ := age.GenerateX25519Identity()
privA, _ := NewAgePrivacy(idA, []age.Recipient{idA.Recipient()})
privB, _ := NewAgePrivacy(idB, []age.Recipient{idB.Recipient()})
// Seal with key A.
docA := NewDocument("doc", "n", WithPrivacy(privA))
ops := []Operation{{Field: "f", FieldType: FieldTypeCounter, Data: []byte("42")}}
envs, err := docA.SealOps(ops)
if err != nil {
t.Fatalf("SealOps: %v", err)
}
// Try to open with key B (same tag "age/v1" but wrong key).
docB := NewDocument("doc", "n", WithPrivacy(privB))
_, err = docB.OpenOps(envs)
if err == nil {
t.Fatal("VULNERABLE: age ciphertext decrypted with wrong key — no error")
}
// Must be a decryption error, NOT silent garbage.
t.Logf("Correctly rejected wrong-key ciphertext: %v", err)
}
// // Same key K used for doc A and doc B. Envelope sealed for A can be
// applied to B because OpEnvelope has no doc-id binding.
// dispatches to sm.HandleSync. It does NOT check privacy at all.
// If the underlying doc has age privacy, the bridge bypasses it by
// sending bare Ops through HandleSync.
func TestConcurrentSealOpsRace(t *testing.T) {
id, _ := age.GenerateX25519Identity()
priv, _ := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
doc := NewDocument("race-doc", "n", WithPrivacy(priv))
ops := []Operation{
{Field: "f", FieldType: FieldTypeCounter, Data: []byte("data")},
}
var wg sync.WaitGroup
errs := make(chan error, 16)
for i := 0; i < 16; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
envs, err := doc.SealOps(ops)
if err != nil {
errs <- fmt.Errorf("SealOps goroutine %d: %w", i, err)
return
}
_, err = doc.OpenOps(envs)
if err != nil {
errs <- fmt.Errorf("OpenOps goroutine %d: %w", i, err)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("race error: %v", err)
}
}
func TestEmptyEnvelopesEmptyOps(t *testing.T) {
sm := NewSyncManager(nil)
sm.GetOrCreateDocument("empty-doc", "server")
msg := SyncMessage{
Type: SyncUpdate,
DocID: "empty-doc",
Envelopes: []OpEnvelope{},
}
raw, _ := json.Marshal(msg)
resp, err := sm.HandleSync("client", raw)
if err != nil {
t.Fatalf("empty sync should not error: %v", err)
}
// resp should be nil (no-op).
if resp != nil {
t.Fatalf("expected nil response for empty sync, got %d bytes", len(resp))
}
}
func TestNilEnvelopesNilOps(t *testing.T) {
sm := NewSyncManager(nil)
sm.GetOrCreateDocument("nil-doc", "server")
msg := SyncMessage{
Type: SyncUpdate,
DocID: "nil-doc",
}
raw, _ := json.Marshal(msg)
resp, err := sm.HandleSync("client", raw)
if err != nil {
t.Fatalf("nil sync should not error: %v", err)
}
if resp != nil {
t.Fatalf("expected nil response, got %d bytes", len(resp))
}
}
// // Document.Encode() produces a gob snapshot in plaintext regardless
// of the privacy backend. Decode() reconstructs without privacy.
func TestSnapshotBypassesPrivacy(t *testing.T) {
id, _ := age.GenerateX25519Identity()
priv, _ := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
doc := NewDocument("snap-doc", "nodeA", WithPrivacy(priv))
doc.GetText("title").InsertText(-1, "top-secret")
doc.GetRegister("class").Set("classified", Timestamp{Time: 1, NodeID: "nodeA"})
data, err := doc.Encode()
if err != nil {
t.Fatalf("Encode: %v", err)
}
// The encoded blob must NOT contain plaintext when age backend is active.
if bytesContainString(data, "top-secret") {
t.Fatal("VULNERABLE: Encode() leaks plaintext for age-configured doc")
}
// Decode without privacy opts must fail (sealed snapshot).
_, err = Decode(data, "nodeB")
if err == nil {
t.Fatal("VULNERABLE: sealed snapshot decoded without privacy backend")
}
// Decode with correct privacy opts must reconstruct the document.
doc2, err := Decode(data, "nodeB", WithPrivacy(priv))
if err != nil {
t.Fatalf("Decode with privacy: %v", err)
}
if doc2.Privacy().Name() != "age/v1" {
t.Fatalf("decoded doc privacy: %q, expected age/v1", doc2.Privacy().Name())
}
title := doc2.GetText("title").ToString()
if title != "top-secret" {
t.Fatalf("decoded title: %q, expected top-secret", title)
}
}
// TestBareOpsStillWorkForPlaintextDoc confirms the fix is surgical —
// plaintext documents continue to accept legacy bare-Ops messages for
// backwards compatibility with callers that predate the privacy layer.
func TestCrossDocReplayRejected(t *testing.T) {
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("identity: %v", err)
}
priv, err := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
if err != nil {
t.Fatalf("NewAgePrivacy: %v", err)
}
// Doc A seals a real op.
docA := NewDocument("doc-A", "nodeA", WithPrivacy(priv))
docA.GetText("body").InsertText(-1, "secret")
opsA := docA.Diff(nil)
envsA, err := docA.SealOps(opsA)
if err != nil {
t.Fatalf("SealOps: %v", err)
}
if len(envsA) == 0 {
t.Fatal("no envelopes sealed")
}
// Attacker captures envsA and hands them to doc B (same key).
docB := NewDocument("doc-B", "nodeB", WithPrivacy(priv))
_, err = docB.OpenOps(envsA)
if err == nil {
t.Fatal("doc B accepted an envelope sealed for doc A — cross-doc replay possible")
}
if !errors.Is(err, ErrDocIDReplay) {
t.Fatalf("expected ErrDocIDReplay, got: %v", err)
}
// Sanity: doc A can still open its own envelopes.
if _, err := docA.OpenOps(envsA); err != nil {
t.Fatalf("doc A must still open its own envelopes: %v", err)
}
}
// TestDocIDBindingRoundTripsUnchanged proves the wrap/unwrap preserves
// Operation semantics — the `Field`, `FieldType`, and original `Data`
// bytes round-trip through Seal → Open identically.
func TestDocIDBindingRoundTripsUnchanged(t *testing.T) {
id, _ := age.GenerateX25519Identity()
priv, _ := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
doc := NewDocument("rt-doc", "nodeA", WithPrivacy(priv))
original := []Operation{
{Field: "counter-a", FieldType: FieldTypeCounter, Data: []byte(`42`)},
{Field: "reg-x", FieldType: FieldTypeRegister, Data: []byte(`{"v":"hello"}`)},
}
envs, err := doc.SealOps(original)
if err != nil {
t.Fatalf("SealOps: %v", err)
}
opened, err := doc.OpenOps(envs)
if err != nil {
t.Fatalf("OpenOps: %v", err)
}
if len(opened) != len(original) {
t.Fatalf("count mismatch: got %d, want %d", len(opened), len(original))
}
for i := range original {
if opened[i].Field != original[i].Field ||
opened[i].FieldType != original[i].FieldType ||
string(opened[i].Data) != string(original[i].Data) {
t.Fatalf("op %d mismatch: got %+v, want %+v", i, opened[i], original[i])
}
}
}
// TestRawBlobRejectedAsMissingDocID — a ciphertext without the docID
// header is rejected. Even with valid PrivacyTag and AEAD, the unwrap
// fails and OpenOps returns ErrDocIDReplay.
func TestRawBlobRejectedAsMissingDocID(t *testing.T) {
id, _ := age.GenerateX25519Identity()
priv, _ := NewAgePrivacy(id, []age.Recipient{id.Recipient()})
doc := NewDocument("check", "nodeA", WithPrivacy(priv))
// Encrypt an op directly via the Privacy backend, bypassing SealOps —
// this produces a ciphertext with no docID wrapper.
raw := Operation{Field: "x", FieldType: FieldTypeCounter, Data: []byte(`1`)}
rawCT, err := priv.EncryptOp(raw)
if err != nil {
t.Fatalf("EncryptOp: %v", err)
}
envs := []OpEnvelope{{PrivacyTag: priv.Name(), Ciphertext: rawCT}}
_, err = doc.OpenOps(envs)
if err == nil {
t.Fatal("OpenOps accepted an envelope with no docID wrapper")
}
if !errors.Is(err, ErrDocIDReplay) {
t.Fatalf("expected ErrDocIDReplay, got: %v", err)
}
}