diff options
Diffstat (limited to 'yamn')
| -rw-r--r-- | yamn/README.md | 14 | ||||
| -rw-r--r-- | yamn/cmd/yamn-encode/main.go | 55 | ||||
| -rw-r--r-- | yamn/encoder/encoder.go | 400 | ||||
| -rw-r--r-- | yamn/encoder/encoder_test.go | 115 | ||||
| -rw-r--r-- | yamn/go.mod | 7 | ||||
| -rw-r--r-- | yamn/go.sum | 4 |
6 files changed, 595 insertions, 0 deletions
diff --git a/yamn/README.md b/yamn/README.md new file mode 100644 index 0000000..24ce452 --- /dev/null +++ b/yamn/README.md @@ -0,0 +1,14 @@ +# YAMN encoder + +This module contains the active YAMN v2 encoder used by Yamnweb. It creates +the encrypted YAMN envelope locally and does not perform network transport. + +Build and test it from this directory: + +```bash +GOCACHE=/tmp/yamnweb-go-build go test ./... +go build -o /usr/local/bin/yamn-encode ./cmd/yamn-encode +``` + +The Katzenpost standby proof of concept is maintained separately in the +top-level `katzenpost/` module. diff --git a/yamn/cmd/yamn-encode/main.go b/yamn/cmd/yamn-encode/main.go new file mode 100644 index 0000000..70f6494 --- /dev/null +++ b/yamn/cmd/yamn-encode/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "git.virebent.art/virebent/yamnweb/yamn/encoder" +) + +type request struct { + Kind encoder.Kind `json:"kind"` + PublicKeyring string `json:"public_keyring"` + Entry string `json:"entry"` + Chain []string `json:"chain"` + From string `json:"from"` + ReplyTo string `json:"reply_to"` + To string `json:"to"` + Subject string `json:"subject"` + Newsgroup string `json:"newsgroup"` + Body string `json:"body"` + References string `json:"references"` +} + +type response struct { + Success bool `json:"success"` + EntryAddress string `json:"entry_address,omitempty"` + Envelope string `json:"envelope,omitempty"` + Error string `json:"error,omitempty"` +} + +func main() { + var input request + data, err := io.ReadAll(io.LimitReader(os.Stdin, 256*1024)) + if err == nil { + err = json.Unmarshal(data, &input) + } + if err == nil { + result, encodeErr := encoder.Encode(encoder.Request{ + Kind: input.Kind, PublicKeyring: input.PublicKeyring, Entry: input.Entry, + Chain: input.Chain, From: input.From, ReplyTo: input.ReplyTo, To: input.To, + Subject: input.Subject, Newsgroup: input.Newsgroup, Body: input.Body, + References: input.References, + }) + if encodeErr == nil { + _ = json.NewEncoder(os.Stdout).Encode(response{Success: true, EntryAddress: result.EntryAddress, Envelope: string(result.Envelope)}) + return + } + err = encodeErr + } + _ = json.NewEncoder(os.Stdout).Encode(response{Success: false, Error: "YAMN encoding failed"}) + fmt.Fprintln(os.Stderr, "yamn-encode:", err) + os.Exit(1) +} diff --git a/yamn/encoder/encoder.go b/yamn/encoder/encoder.go new file mode 100644 index 0000000..5eacdf3 --- /dev/null +++ b/yamn/encoder/encoder.go @@ -0,0 +1,400 @@ +// Package encoder implements the send-only YAMN v2 client encoder. +package encoder + +import ( + "bufio" + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net/mail" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/blake2s" + "golang.org/x/crypto/nacl/box" +) + +const ( + maxChainLength = 10 + headerBytes = 256 + encHeadBytes = 160 + bodyBytes = 17920 + maxPlainBytes = 17910 + messageBytes = maxChainLength*headerBytes + bodyBytes + armorVersion = "0.2.7" +) + +var ( + ErrInvalidRequest = errors.New("invalid YAMN request") + ErrInvalidKeyring = errors.New("invalid YAMN public keyring") +) + +type Kind string + +const ( + Email Kind = "email" + Usenet Kind = "usenet" +) + +// Request contains one plaintext message and an entry-to-exit chain. +// PublicKeyring is a local pubring.mix path and is never fetched by Encode. +type Request struct { + Kind Kind + PublicKeyring string + Entry string + Chain []string + From string + ReplyTo string + To string + Subject string + Newsgroup string + Body string + References string +} + +type Result struct { + EntryAddress string + Envelope []byte +} + +type remailerKey struct { + name, address string + keyID, publicKey []byte +} + +// Encode creates an armored YAMN v2 packet. It performs no network I/O. +func Encode(r Request) (Result, error) { + if err := Validate(r); err != nil { + return Result{}, err + } + keys, err := loadKeyring(r.PublicKeyring) + if err != nil { + return Result{}, err + } + chain := make([]remailerKey, len(r.Chain)) + for i, name := range r.Chain { + key, ok := keys[name] + if !ok { + return Result{}, fmt.Errorf("remailer %q not found in keyring", name) + } + chain[i] = key + } + plain, err := composeMessage(r) + if err != nil { + return Result{}, err + } + packet, err := encodePacket(plain, chain) + if err != nil { + return Result{}, err + } + return Result{EntryAddress: chain[0].address, Envelope: armor(packet)}, nil +} + +func Validate(r Request) error { + if r.Kind != Email && r.Kind != Usenet { + return fmt.Errorf("%w: unsupported message kind", ErrInvalidRequest) + } + if strings.TrimSpace(r.PublicKeyring) == "" { + return fmt.Errorf("%w: missing public keyring", ErrInvalidRequest) + } + if len(r.Chain) == 0 || len(r.Chain) > maxChainLength { + return fmt.Errorf("%w: invalid chain length", ErrInvalidRequest) + } + for _, hop := range r.Chain { + if !isRemailerName(hop) { + return fmt.Errorf("%w: invalid remailer name", ErrInvalidRequest) + } + } + if strings.TrimSpace(r.Entry) != "" && strings.TrimSpace(r.Entry) != r.Chain[0] { + return fmt.Errorf("%w: entry does not match chain", ErrInvalidRequest) + } + if strings.TrimSpace(r.Body) == "" || len([]byte(r.Body)) > maxPlainBytes { + return fmt.Errorf("%w: body is empty or too large", ErrInvalidRequest) + } + if r.Kind == Email { + if _, err := mail.ParseAddress(r.To); err != nil { + return fmt.Errorf("%w: invalid recipient address", ErrInvalidRequest) + } + } else { + if strings.TrimSpace(r.Newsgroup) == "" || !validHeaderValue(r.Newsgroup) { + return fmt.Errorf("%w: missing or invalid newsgroup", ErrInvalidRequest) + } + if _, err := mail.ParseAddress(r.To); err != nil { + return fmt.Errorf("%w: invalid Usenet gateway recipient", ErrInvalidRequest) + } + } + for _, value := range []string{r.From, r.ReplyTo, r.To, r.Subject, r.Newsgroup, r.References} { + if !validHeaderValue(value) { + return fmt.Errorf("%w: invalid header value", ErrInvalidRequest) + } + } + return nil +} + +func composeMessage(r Request) ([]byte, error) { + var b strings.Builder + b.WriteString("Content-Type: text/plain; charset=utf-8\nContent-Transfer-Encoding: 8bit\nMIME-Version: 1.0\n") + if r.From != "" { + b.WriteString("From: " + r.From + "\n") + } + if r.ReplyTo != "" { + b.WriteString("Reply-To: " + r.ReplyTo + "\n") + } + if r.To != "" { + b.WriteString("To: " + r.To + "\n") + } + if r.Subject != "" { + b.WriteString("Subject: " + r.Subject + "\n") + } + if r.Newsgroup != "" { + b.WriteString("Newsgroups: " + r.Newsgroup + "\n") + } + if r.References != "" { + b.WriteString("References: " + r.References + "\n") + } + b.WriteString("\n") + b.WriteString(r.Body) + return []byte(b.String()), nil +} + +func validHeaderValue(value string) bool { return !strings.ContainsAny(value, "\r\n\x00") } +func isRemailerName(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if !(c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { + return false + } + } + return true +} + +func loadKeyring(path string) (map[string]remailerKey, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open public keyring: %w", err) + } + defer f.Close() + keys := make(map[string]remailerKey) + s := bufio.NewScanner(f) + var current remailerKey + var phase int + for s.Scan() { + line := strings.TrimSpace(s.Text()) + switch phase { + case 0: + parts := strings.Fields(line) + if len(parts) != 7 { + continue + } + id, e := hex.DecodeString(parts[2]) + if e != nil || len(id) != 16 { + continue + } + from, e1 := time.Parse("2006-01-02", parts[5]) + until, e2 := time.Parse("2006-01-02", parts[6]) + if e1 != nil || e2 != nil { + continue + } + now := time.Now().UTC() + if now.Before(from) || now.After(until.Add(24*time.Hour)) { + continue + } + current = remailerKey{name: parts[0], address: parts[1], keyID: id} + phase = 1 + case 1: + if line == "-----Begin Mix Key-----" { + phase = 2 + } + case 2: + id, e := hex.DecodeString(line) + if e != nil || !bytes.Equal(id, current.keyID) { + phase = 0 + } else { + phase = 3 + } + case 3: + key, e := hex.DecodeString(line) + if e != nil || len(key) != 32 { + phase = 0 + } else { + current.publicKey = key + phase = 4 + } + case 4: + if line == "-----End Mix Key-----" { + keys[current.name] = current + } + phase = 0 + } + } + if err := s.Err(); err != nil { + return nil, fmt.Errorf("read public keyring: %w", err) + } + if len(keys) == 0 { + return nil, ErrInvalidKeyring + } + return keys, nil +} + +func encodePacket(plain []byte, chain []remailerKey) ([]byte, error) { + if len(chain) == 0 || len(chain) > maxChainLength || len(plain) > maxPlainBytes { + return nil, ErrInvalidRequest + } + payload := make([]byte, messageBytes) + if _, err := io.ReadFull(rand.Reader, payload); err != nil { + return nil, err + } + copy(payload[maxChainLength*headerBytes:], plain) + keys := make([][]byte, len(chain)-1) + ivs := make([][]byte, len(chain)-1) + for i := range keys { + keys[i] = randomBytes(32) + ivs[i] = randomBytes(12) + } + finalIV := randomBytes(16) + finalID := randomBytes(16) + exitAES := randomBytes(32) + final := make([]byte, 64) + copy(final, finalIV) + final[16], final[17] = 1, 1 + copy(final[18:], finalID) + binary.LittleEndian.PutUint32(final[34:38], uint32(len(plain))) + copy(payload[maxChainLength*headerBytes:], aesCTR(payload[maxChainLength*headerBytes:], exitAES, finalIV)) + shiftHeaders(payload) + if len(chain) > 1 { + deterministic(payload, keys, ivs, len(chain), 0) + } + exitData := slotData(1, finalID, exitAES, final, antiTag(payload)) + copy(payload[:headerBytes], encryptedHeader(chain[len(chain)-1], exitData)) + for hop := 0; hop < len(chain)-1; hop++ { + partial := ivs[hop] + next := chain[len(chain)-hop-1].address + info := make([]byte, 64) + copy(info, partial) + copy(info[12:], []byte(next)) + encryptAll(payload, keys[hop], ivs[hop]) + shiftHeaders(payload) + deterministic(payload, keys, ivs, len(chain), hop+1) + data := slotData(0, randomBytes(16), keys[hop], info, antiTag(payload)) + copy(payload[:headerBytes], encryptedHeader(chain[len(chain)-hop-2], data)) + } + return payload, nil +} + +func slotData(kind byte, packetID, aesKey, info, tag []byte) []byte { + b := make([]byte, encHeadBytes) + b[0], b[1], b[2] = 2, kind, 0 + copy(b[3:], packetID) + copy(b[19:], aesKey) + binary.LittleEndian.PutUint16(b[51:53], uint16(time.Now().UTC().Unix()/86400)) + copy(b[53:], info) + copy(b[117:], tag) + return b +} + +func encryptedHeader(key remailerKey, data []byte) []byte { + var recipient, sender [32]byte + copy(recipient[:], key.publicKey) + if _, err := io.ReadFull(rand.Reader, sender[:]); err != nil { + panic(err) + } + var nonce [24]byte + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + panic(err) + } + sealed := box.Seal(nil, data, &nonce, &recipient, &sender) + b := make([]byte, headerBytes) + copy(b, key.keyID) + copy(b[16:], sender[:]) + copy(b[48:], nonce[:]) + copy(b[72:], sealed) + return b +} + +func aesCTR(input, key, iv []byte) []byte { + block, err := aes.NewCipher(key) + if err != nil { + panic(err) + } + output := make([]byte, len(input)) + cipher.NewCTR(block, iv).XORKeyStream(output, input) + return output +} +func randomBytes(n int) []byte { + b := make([]byte, n) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + panic(err) + } + return b +} +func shiftHeaders(p []byte) { + copy(p[headerBytes:maxChainLength*headerBytes], p[:(maxChainLength-1)*headerBytes]) +} +func antiTag(p []byte) []byte { + h, _ := blake2s.New256(nil) + h.Write(p[headerBytes:]) + return h.Sum(nil) +} +func seqIV(partial []byte, slot int) []byte { + iv := make([]byte, 16) + copy(iv[:4], partial[:4]) + binary.LittleEndian.PutUint32(iv[4:8], uint32(slot)) + copy(iv[8:], partial[4:]) + return iv +} +func encryptAll(p, key, partial []byte) { + for slot := 0; slot <= maxChainLength; slot++ { + start := slot * headerBytes + end := start + headerBytes + if slot == maxChainLength { + start = maxChainLength * headerBytes + end = len(p) + } + copy(p[start:end], aesCTR(p[start:end], key, seqIV(partial, slot))) + } +} +func deterministic(p []byte, keys, ivs [][]byte, chainLen, hop int) { + bottom := maxChainLength - 1 + top := bottom - (chainLen - hop - 2) + for slot := top; slot <= bottom; slot++ { + fake := make([]byte, headerBytes) + use := bottom + for i := bottom - slot + hop; i >= 0; i-- { + copy(fake, aesCTR(fake, keys[i], seqIV(ivs[i], use))) + use-- + } + copy(p[slot*headerBytes:(slot+1)*headerBytes], fake) + } +} + +func armor(payload []byte) []byte { + h, _ := blake2s.New256(nil) + h.Write(payload) + var b bytes.Buffer + b.WriteString("::\nRemailer-Type: yamn-" + armorVersion + "\n\n-----BEGIN REMAILER MESSAGE-----\n") + b.WriteString(strconv.Itoa(len(payload)) + "\n") + b.WriteString(hex.EncodeToString(h.Sum(nil)) + "\n") + encoded := base64.StdEncoding.EncodeToString(payload) + for len(encoded) > 0 { + n := 64 + if len(encoded) < n { + n = len(encoded) + } + b.WriteString(encoded[:n]) + b.WriteByte('\n') + encoded = encoded[n:] + } + b.WriteString("\n-----END REMAILER MESSAGE-----\n") + return b.Bytes() +} diff --git a/yamn/encoder/encoder_test.go b/yamn/encoder/encoder_test.go new file mode 100644 index 0000000..a6ea192 --- /dev/null +++ b/yamn/encoder/encoder_test.go @@ -0,0 +1,115 @@ +package encoder + +import ( + "encoding/base64" + "os" + "strings" + "testing" +) + +func testKeyring(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/pubring.mix" + var content strings.Builder + for i, name := range []string{"entry", "middle", "exit"} { + keyID := strings.Repeat(string(rune('a'+i)), 32) + publicKey := strings.Repeat(string(rune('d'+i)), 64) + content.WriteString(name + " " + name + "@example.org " + keyID + " 4:0.2c E 2025-01-01 2099-12-31\n\n") + content.WriteString("-----Begin Mix Key-----\n" + keyID + "\n" + publicKey + "\n-----End Mix Key-----\n") + } + if err := os.WriteFile(path, []byte(content.String()), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestValidateEmail(t *testing.T) { + r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "user@example.org", Body: "hello"} + if err := Validate(r); err != nil { + t.Fatal(err) + } +} + +func TestValidateRejectsPlainIncompleteRequest(t *testing.T) { + r := Request{Kind: Email, PublicKeyring: "/tmp/pubring.mix", Entry: "entry", Chain: []string{"entry"}, To: "bad", Body: "hello"} + if err := Validate(r); err == nil { + t.Fatal("expected invalid recipient") + } +} + +func TestValidateUsenetRequiresGatewayRecipient(t *testing.T) { + r := Request{Kind: Usenet, PublicKeyring: "/tmp/pubring.mix", Chain: []string{"entry"}, Newsgroup: "misc.test", Body: "hello"} + if err := Validate(r); err == nil { + t.Fatal("expected missing Usenet gateway recipient to be rejected") + } + r.To = "mail2news@example.org" + if err := Validate(r); err != nil { + t.Fatalf("expected valid Usenet request: %v", err) + } +} + +func TestComposeUsenetGatewayHeaders(t *testing.T) { + plain, err := composeMessage(Request{ + Kind: Usenet, To: "mail2news@example.org", Subject: "test", Newsgroup: "misc.test", Body: "hello", + }) + if err != nil { + t.Fatal(err) + } + text := string(plain) + if !strings.Contains(text, "To: mail2news@example.org\n") || !strings.Contains(text, "Newsgroups: misc.test\n") { + t.Fatalf("missing Usenet delivery headers: %q", text) + } +} + +func TestEncodeProducesYAMNArmor(t *testing.T) { + keyring := testKeyring(t) + result, err := Encode(Request{ + Kind: Email, PublicKeyring: keyring, Entry: "entry", Chain: []string{"entry"}, + From: "Anonymous <anon@example.org>", To: "user@example.org", Subject: "test", Body: "hello", + }) + if err != nil { + t.Fatal(err) + } + if result.EntryAddress != "entry@example.org" { + t.Fatalf("unexpected entry address: %q", result.EntryAddress) + } + text := string(result.Envelope) + if !strings.Contains(text, "-----BEGIN REMAILER MESSAGE-----") || !strings.Contains(text, "-----END REMAILER MESSAGE-----") { + t.Fatal("missing YAMN armor markers") + } + lines := strings.Split(text, "\n") + start := 0 + for i, line := range lines { + if line == "-----BEGIN REMAILER MESSAGE-----" { + start = i + 3 + break + } + } + var encoded strings.Builder + for _, line := range lines[start:] { + if line == "" || strings.HasPrefix(line, "-----END") { + break + } + encoded.WriteString(line) + } + packet, err := base64.StdEncoding.DecodeString(encoded.String()) + if err != nil { + t.Fatal(err) + } + if len(packet) != messageBytes { + t.Fatalf("unexpected packet size: got %d, want %d", len(packet), messageBytes) + } +} + +func TestEncodeMultiHop(t *testing.T) { + result, err := Encode(Request{ + Kind: Email, PublicKeyring: testKeyring(t), Entry: "entry", + Chain: []string{"entry", "middle", "exit"}, To: "user@example.org", Body: "hello", + }) + if err != nil { + t.Fatal(err) + } + if len(result.Envelope) == 0 || result.EntryAddress != "entry@example.org" { + t.Fatal("multi-hop envelope was not produced") + } +} diff --git a/yamn/go.mod b/yamn/go.mod new file mode 100644 index 0000000..04e0e46 --- /dev/null +++ b/yamn/go.mod @@ -0,0 +1,7 @@ +module git.virebent.art/virebent/yamnweb/yamn + +go 1.26.2 + +require golang.org/x/crypto v0.51.0 + +require golang.org/x/sys v0.44.0 // indirect diff --git a/yamn/go.sum b/yamn/go.sum new file mode 100644 index 0000000..c826b88 --- /dev/null +++ b/yamn/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
