prototype encrypted messages
Ted Unangst tedu@tedunangst.com
Fri, 05 Jan 2024 11:47:29 -0500
7 files changed,
272 insertions(+),
21 deletions(-)
M
activity.go
→
activity.go
@@ -47,6 +47,7 @@
const itiswhatitis = "https://www.w3.org/ns/activitystreams" const thewholeworld = "https://www.w3.org/ns/activitystreams#Public" const tinyworld = "as:Public" +const chatKeyProp = "chatKeyV0" var fastTimeout time.Duration = 5 var slowTimeout time.Duration = 30@@ -1117,9 +1118,25 @@ }
imaginate(&xonk) if what == "chonk" { - target, _ := obj.GetString("to") + // undo damage above + xonk.Noise = strings.TrimPrefix(xonk.Noise, "<p>") + target := firstofmany(obj, "to") if target == user.URL { target = xonk.Honker + } + enc, _ := obj.GetString(chatKeyProp) + if enc != "" { + var dec string + if pubkey, ok := getchatkey(xonk.Honker); ok { + dec, err = decryptString(xonk.Noise, user.ChatSecKey, pubkey) + if err != nil { + ilog.Printf("failed to decrypt chonk") + } + } + if err == nil { + dlog.Printf("successful decrypt from %s", xonk.Honker) + xonk.Noise = dec + } } ch := Chonk{ UserID: xonk.UserID,@@ -1499,7 +1516,7 @@ }
return rcpts } -func chonkifymsg(user *WhatAbout, ch *Chonk) []byte { +func chonkifymsg(user *WhatAbout, rcpt string, ch *Chonk) []byte { dt := ch.Date.Format(time.RFC3339) aud := []string{ch.Target}@@ -1509,7 +1526,19 @@ jo["type"] = "ChatMessage"
jo["published"] = dt jo["attributedTo"] = user.URL jo["to"] = aud - jo["content"] = ch.HTML + content := string(ch.HTML) + if user.ChatSecKey.key != nil { + if pubkey, ok := getchatkey(rcpt); ok { + var err error + content, err = encryptString(content, user.ChatSecKey, pubkey) + if err != nil { + ilog.Printf("failure encrypting chonk: %s", err) + } + jo[chatKeyProp] = user.Options.ChatPubKey + } + } + jo["content"] = content + atts := activatedonks(ch.Donks) if len(atts) > 0 { jo["attachment"] = atts@@ -1544,11 +1573,10 @@ return j.ToBytes()
} func sendchonk(user *WhatAbout, ch *Chonk) { - msg := chonkifymsg(user, ch) - rcpts := make(map[string]bool) rcpts[ch.Target] = true for a := range rcpts { + msg := chonkifymsg(user, a, ch) go deliverate(user.ID, a, msg) } }@@ -1670,6 +1698,7 @@ k["id"] = user.URL + "#key"
k["owner"] = user.URL k["publicKeyPem"] = user.Key j["publicKey"] = k + j[chatKeyProp] = user.Options.ChatPubKey return j }@@ -1791,12 +1820,27 @@ return info, nil
} func allinjest(origin string, obj junk.Junk) { + ident, _ := obj.GetString("id") + if ident == "" { + return + } + if originate(ident) != origin { + return + } keyobj, ok := obj.GetMap("publicKey") if ok { ingestpubkey(origin, keyobj) } ingestboxes(origin, obj) ingesthandle(origin, obj) + chatkey, ok := obj.GetString(chatKeyProp) + if ok { + when := time.Now().UTC().Format(dbtimeformat) + _, err := stmtSaveXonker.Exec(ident, chatkey, chatKeyProp, when) + if err != nil { + elog.Printf("error saving chatkey: %s", err) + } + } } func ingestpubkey(origin string, obj junk.Junk) {
M
database.go
→
database.go
@@ -57,6 +57,8 @@ err = unjsonify(options, &user.Options)
if err != nil { elog.Printf("error processing user options: %s", err) } + user.ChatPubKey.key, _ = b64tokey(user.Options.ChatPubKey) + user.ChatSecKey.key, _ = b64tokey(user.Options.ChatSecKey) } else { user.URL = fmt.Sprintf("https://%s/%s", serverName, user.Name) }
A
docs/encrypted-messages.txt
@@ -0,0 +1,45 @@
+Informal spec for an encrypted chat message prototype + +This is an extension to the ChatMessage activity. +It could apply to other types, but has limited utility for public activities. + +The encryption used is the "nacl box" combination of Curve25519, Salsa20, and Poly1305. +It's widely available in user proof crypto libraries. + +A 32 byte user public key is added to the actor object in base64 format in the "chatKeyV0" property. + +If a ChatMessage object has an "chatKeyV0" property it should be decrypted. +The "content" property is now a base64 encoded message consisting of nonce[24] || cipher[...]. + +To send an encrypted ChatMessage: +1. Add "chatKeyV0" with public key (base64) to one's own actor object. +2. Look up chatKeyV0 for remote actor. +3. Generate a random nonce and call crypto_box. +4. Base64 encode nonce and cipher text, storing as "content". +5. Add "chatKeyV0" property with key. + +Receiving and decrypting: +1. Check for "chatKeyV0" property. +2. Look up chatKeyV0 for remote actor. +3. Base64 decode "content" property. +4. Split into nonce and cipher text, call crypto_box_open. +5. Replace message content. + +The public key is duplicated in the actor and the message. + +Notes + +This doesn't support shared group keys. Messages need to be encrypted per recipient. + +This doesn't use any advanced ratcheting techniques. +Not sure how well they fit in with ActivityPub. +Limited library availability. + +Random nonces are fine and should be used. +ActivityPub IDs should be unique, but it's better to avoid the possiblity of duplicates. + +Keys should be verified at some point. + +It's only secure if the secret keys are kept somewhere secret. + +It's V0.
A
encrypt.go
@@ -0,0 +1,115 @@
+package main + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "strings" + "time" + + "golang.org/x/crypto/nacl/box" + "humungus.tedunangst.com/r/webs/gencache" +) + +type boxSecKey struct { + key *[32]byte +} +type boxPubKey struct { + key *[32]byte +} + +func encryptString(plain string, seckey boxSecKey, pubkey boxPubKey) (string, error) { + var nonce [24]byte + rand.Read(nonce[:]) + out := box.Seal(nil, []byte(plain), &nonce, pubkey.key, seckey.key) + + var sb strings.Builder + b64 := base64.NewEncoder(base64.StdEncoding, &sb) + b64.Write(nonce[:]) + b64.Write(out) + b64.Close() + return sb.String(), nil +} + +func decryptString(encmsg string, seckey boxSecKey, pubkey boxPubKey) (string, error) { + var buf bytes.Buffer + b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(encmsg)) + io.Copy(&buf, b64) + data := buf.Bytes() + if len(data) < 24 { + return "", fmt.Errorf("not enough data") + } + var nonce [24]byte + copy(nonce[:], data) + data = data[24:] + out, ok := box.Open(nil, data, &nonce, pubkey.key, seckey.key) + if !ok { + return "", fmt.Errorf("error decrypting chonk") + } + return string(out), nil +} + +func b64tokey(s string) (*[32]byte, error) { + var buf bytes.Buffer + b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(s)) + n, _ := io.Copy(&buf, b64) + if n != 32 { + return nil, fmt.Errorf("bad key size") + } + var key [32]byte + copy(key[:], buf.Bytes()) + return &key, nil +} + +func tob64(data []byte) string { + var sb strings.Builder + b64 := base64.NewEncoder(base64.StdEncoding, &sb) + b64.Write(data) + b64.Close() + return sb.String() +} + +func newChatKeys() (boxPubKey, boxSecKey) { + pub, sec, _ := box.GenerateKey(rand.Reader) + return boxPubKey{pub}, boxSecKey{sec} +} + +var chatkeys = gencache.New(gencache.Options[string, boxPubKey]{Fill: func(xonker string) (boxPubKey, bool) { + data := getxonker(xonker, chatKeyProp) + if data == "" { + dlog.Printf("hitting the webs for missing chatkey: %s", xonker) + j, err := GetJunk(readyLuserOne, xonker) + if err != nil { + ilog.Printf("error getting %s: %s", xonker, err) + when := time.Now().UTC().Format(dbtimeformat) + stmtSaveXonker.Exec(xonker, "failed", chatKeyProp, when) + return boxPubKey{}, true + } + allinjest(originate(xonker), j) + data = getxonker(xonker, chatKeyProp) + if data == "" { + ilog.Printf("key not found after ingesting") + when := time.Now().UTC().Format(dbtimeformat) + stmtSaveXonker.Exec(xonker, "failed", chatKeyProp, when) + return boxPubKey{}, true + } + } + if data == "failed" { + ilog.Printf("lookup previously failed chatkey %s", xonker) + return boxPubKey{}, true + } + var pubkey boxPubKey + var err error + pubkey.key, err = b64tokey(data) + if err != nil { + ilog.Printf("error decoding %s pubkey: %s", xonker, err) + } + return pubkey, true +}, Limit: 512}) + +func getchatkey(xonker string) (boxPubKey, bool) { + pubkey, _ := chatkeys.Get(xonker) + return pubkey, pubkey.key != nil +}
M
honk.go
→
honk.go
@@ -25,16 +25,18 @@ "humungus.tedunangst.com/r/webs/httpsig"
) type WhatAbout struct { - ID int64 - Name string - Display string - About string - HTAbout template.HTML - Onts []string - Key string - URL string - Options UserOptions - SecKey httpsig.PrivateKey + ID int64 + Name string + Display string + About string + HTAbout template.HTML + Onts []string + Key string + URL string + Options UserOptions + SecKey httpsig.PrivateKey + ChatPubKey boxPubKey + ChatSecKey boxSecKey } type UserOptions struct {@@ -48,6 +50,8 @@ MapLink string `json:",omitempty"`
Reaction string `json:",omitempty"` MeCount int64 ChatCount int64 + ChatPubKey string + ChatSecKey string } type KeyInfo struct {
M
upgradedb.go
→
upgradedb.go
@@ -23,7 +23,7 @@
"humungus.tedunangst.com/r/webs/htfilter" ) -var myVersion = 47 // idx forme +var myVersion = 48 // chat keys type dbexecer interface { Exec(query string, args ...interface{}) (sql.Result, error)@@ -47,15 +47,25 @@ elog.Fatal("database is too old to upgrade")
} var err error var tx *sql.Tx - try := func(s string, args ...interface{}) { - if tx != nil { - _, err = tx.Exec(s, args...) + try := func(s string, args ...interface{}) *sql.Rows { + var rows *sql.Rows + if strings.HasPrefix(s, "select") { + if tx != nil { + rows, err = tx.Query(s, args...) + } else { + rows, err = db.Query(s, args...) + } } else { - _, err = db.Exec(s, args...) + if tx != nil { + _, err = tx.Exec(s, args...) + } else { + _, err = db.Exec(s, args...) + } } if err != nil { elog.Fatalf("can't run %s: %s", s, err) } + return rows } setV := func(ver int64) { try("update config set value = ? where key = 'dbversion'", ver)@@ -180,6 +190,32 @@ try("create index idx_honksforme on honks(whofore) where whofore = 1")
setV(47) fallthrough case 47: + rows := try("select userid, options from users where userid > 0") + var users []*WhatAbout + for rows.Next() { + var user WhatAbout + var jopt string + err = rows.Scan(&user.ID, &jopt) + if err != nil { + elog.Fatal(err) + } + err = unjsonify(jopt, &user.Options) + if err != nil { + elog.Fatal(err) + } + users = append(users, &user) + } + rows.Close() + for _, user := range users { + chatpubkey, chatseckey := newChatKeys() + user.Options.ChatPubKey = tob64(chatpubkey.key[:]) + user.Options.ChatSecKey = tob64(chatseckey.key[:]) + jopt, _ := jsonify(user.Options) + try("update users set options = ? where userid = ?", jopt, user.ID) + } + setV(48) + fallthrough + case 48: try("analyze") default:
M
util.go
→
util.go
@@ -327,8 +327,13 @@ seckey, err := httpsig.EncodeKey(k)
if err != nil { return err } + chatpubkey, chatseckey := newChatKeys() + var opts UserOptions + opts.ChatPubKey = tob64(chatpubkey.key[:]) + opts.ChatSecKey = tob64(chatseckey.key[:]) + jopt, _ := jsonify(opts) about := "what about me?" - _, err = db.Exec("insert into users (username, displayname, about, hash, pubkey, seckey, options) values (?, ?, ?, ?, ?, ?, ?)", name, name, about, hash, pubkey, seckey, "{}") + _, err = db.Exec("insert into users (username, displayname, about, hash, pubkey, seckey, options) values (?, ?, ?, ?, ?, ?, ?)", name, name, about, hash, pubkey, seckey, jopt) if err != nil { return err }