summaryrefslogtreecommitdiffstats
path: root/yamn
diff options
context:
space:
mode:
authorGab <24553253+gabrix73@users.noreply.github.com>2026-08-13 14:04:30 +0200
committerGab <24553253+gabrix73@users.noreply.github.com>2026-08-13 14:04:30 +0200
commit994071243fc80990cf09e820b671006e9bd31c76 (patch)
treeb65e02724b98033240aa6f9d153acea021abc456 /yamn
parent47d903de3bca4165e96d6ec8830eafbacf524cd2 (diff)
downloadyamnweb-994071243fc80990cf09e820b671006e9bd31c76.tar.gz
yamnweb-994071243fc80990cf09e820b671006e9bd31c76.tar.xz
yamnweb-994071243fc80990cf09e820b671006e9bd31c76.zip
Separate active YAMN code from Katzenpost PoC
Diffstat (limited to 'yamn')
-rw-r--r--yamn/README.md14
-rw-r--r--yamn/cmd/yamn-encode/main.go55
-rw-r--r--yamn/encoder/encoder.go400
-rw-r--r--yamn/encoder/encoder_test.go115
-rw-r--r--yamn/go.mod7
-rw-r--r--yamn/go.sum4
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=