Experimenting with AT Protocol to hit up your friends

Small adjustments to the contents protocol

I'm still not 100% on the crypto here, it seems like maybe I should
start with something simpler for proof-of-concept and if it's really
needed I could always make a v2 record type or something.

Changed files
+36 -90
api
appview
lexicons
public
+1 -3
api/atyo/atyoping.go
··· 17 17 // RECORDTYPE: Ping 18 18 type Ping struct { 19 19 LexiconTypeID string `json:"$type,const=app.atyo.ping" cborgen:"$type,const=app.atyo.ping"` 20 - // contents: A message encrypted for a specific recipient, possibly including an encrypted payload. After decryption, this will deserialize as `app.atyo.ping#contents`. 20 + // contents: A message encrypted for specific recipient(s), possibly including an encrypted payload. After decryption, this will deserialize as `app.atyo.ping#contents`. 21 21 Contents util.LexBytes `json:"contents,omitempty" cborgen:"contents,omitempty"` 22 22 // createdAt: The time of record creation. 23 23 CreatedAt string `json:"createdAt" cborgen:"createdAt"` ··· 25 25 Nonce util.LexBytes `json:"nonce,omitempty" cborgen:"nonce,omitempty"` 26 26 // publicKey: One-time key used to encrypt the message contents 27 27 PublicKey util.LexBytes `json:"publicKey,omitempty" cborgen:"publicKey,omitempty"` 28 - // targets: The one-time private key to decrypt `contents`, which has been encrypted for its recipient(s). 29 - Targets util.LexBytes `json:"targets,omitempty" cborgen:"targets,omitempty"` 30 28 } 31 29 32 30 // Ping_Contents is a "contents" in the app.atyo.ping schema.
+1 -56
api/atyo/cbor_gen.go
··· 189 189 } 190 190 191 191 cw := cbg.NewCborWriter(w) 192 - fieldCount := 6 192 + fieldCount := 5 193 193 194 194 if t.Contents == nil { 195 195 fieldCount-- ··· 200 200 } 201 201 202 202 if t.PublicKey == nil { 203 - fieldCount-- 204 - } 205 - 206 - if t.Targets == nil { 207 203 fieldCount-- 208 204 } 209 205 ··· 253 249 } 254 250 255 251 if _, err := cw.Write(t.Nonce); err != nil { 256 - return err 257 - } 258 - 259 - } 260 - 261 - // t.Targets (util.LexBytes) (slice) 262 - if t.Targets != nil { 263 - 264 - if len("targets") > 1000000 { 265 - return xerrors.Errorf("Value in field \"targets\" was too long") 266 - } 267 - 268 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targets"))); err != nil { 269 - return err 270 - } 271 - if _, err := cw.WriteString(string("targets")); err != nil { 272 - return err 273 - } 274 - 275 - if len(t.Targets) > 2097152 { 276 - return xerrors.Errorf("Byte array in field t.Targets was too long") 277 - } 278 - 279 - if err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Targets))); err != nil { 280 - return err 281 - } 282 - 283 - if _, err := cw.Write(t.Targets); err != nil { 284 252 return err 285 253 } 286 254 ··· 439 407 } 440 408 441 409 if _, err := io.ReadFull(cr, t.Nonce); err != nil { 442 - return err 443 - } 444 - 445 - // t.Targets (util.LexBytes) (slice) 446 - case "targets": 447 - 448 - maj, extra, err = cr.ReadHeader() 449 - if err != nil { 450 - return err 451 - } 452 - 453 - if extra > 2097152 { 454 - return fmt.Errorf("t.Targets: byte array too large (%d)", extra) 455 - } 456 - if maj != cbg.MajByteString { 457 - return fmt.Errorf("expected byte array") 458 - } 459 - 460 - if extra > 0 { 461 - t.Targets = make([]uint8, extra) 462 - } 463 - 464 - if _, err := io.ReadFull(cr, t.Targets); err != nil { 465 410 return err 466 411 } 467 412
+32 -25
appview/ping.go
··· 1 1 package appview 2 2 3 3 import ( 4 + "bytes" 4 5 "crypto/rand" 6 + "encoding/hex" 5 7 "encoding/json" 6 8 "fmt" 7 9 "net/http" ··· 39 41 return 40 42 } 41 43 42 - fmt.Fprintln(os.Stderr, "would write ping to PDS: "+string(jsonPing)) 44 + cborBuf := bytes.NewBuffer([]byte{}) 45 + err = ping.MarshalCBOR(cborBuf) 46 + if err == nil { 47 + fmt.Fprintln(os.Stderr, "ping length as CBOR:", cborBuf.Len()) 48 + } else { 49 + fmt.Fprintln(os.Stderr, "failed to marshal as CBOR", err) 50 + } 51 + 52 + fmt.Fprintf(os.Stderr, hex.EncodeToString(cborBuf.Bytes())+"\n") 53 + 54 + fmt.Fprintln( 55 + os.Stderr, 56 + "would write ping to PDS: "+string(jsonPing), 57 + ) 43 58 44 59 // TODO write records to our PDS 45 60 ··· 49 64 } 50 65 } 51 66 67 + // Build an app.atyo.ping record. Notes about the format of `contents`: 68 + // 69 + // Roughly based on the Scuttlebutt protocol for pivate messages: 70 + // https://ssbc.github.io/scuttlebutt-protocol-guide/#private-messages 52 71 func (s *Server) buildPing(target *identity.Identity) (*atyo.Ping, *httpError) { 53 - const nonceLen = 24 54 - var nonce [nonceLen]byte 55 - if len, err := rand.Read(nonce[:]); len != nonceLen || err != nil { 56 - return nil, &httpError{ 57 - http.StatusInternalServerError, 58 - "rand.Read returned unexpected result", 59 - } 60 - } 72 + var nonce [24]byte 73 + _, _ = rand.Read(nonce[:]) 61 74 62 75 sharedPubKey, sharedSecretKey, err := box.GenerateKey(rand.Reader) 63 76 if err != nil { ··· 68 81 } 69 82 70 83 // TODO: this is where e.g. geo contents would go 71 - message := padMessage(nil) 84 + // Maybe we should do padding after calculating shared key len 85 + message := padMessage([]byte("Hello secret msg")) 72 86 73 87 targetPubkey, err := s.keys.fetchUserPubKey(target) 74 88 if err != nil { ··· 78 92 } 79 93 } 80 94 81 - encryptedMessage, err := box.SealAnonymous(nil, message, sharedPubKey, rand.Reader) 82 - if err != nil { 83 - return nil, &httpError{http.StatusInternalServerError, 84 - "Failed to encrypt message payload", 85 - } 95 + // NOTE: sealing this way effectively makes sharedSecret a symmetric key, I think? 96 + // Or maybe just makes the message unencrypted, tbh not completely sure. 97 + encryptedMessage := box.Seal(nil, message, &nonce, sharedPubKey, sharedSecretKey) 86 98 87 - } 99 + encSharedKey := box.Seal(nil, sharedSecretKey[:], &nonce, &targetPubkey, sharedSecretKey) 88 100 89 - encSharedKey, err := box.SealAnonymous(nil, sharedSecretKey[:], &targetPubkey, rand.Reader) 90 - if err != nil { 91 - return nil, &httpError{ 92 - http.StatusInternalServerError, 93 - "Failed to encrypt shared secret for recipient", 94 - } 95 - } 101 + contents := append(encSharedKey, encryptedMessage...) 102 + 103 + fmt.Fprintln(os.Stderr, "contents total len: ", len(contents)) 96 104 97 105 return &atyo.Ping{ 98 - Contents: encryptedMessage, 106 + Contents: contents, 99 107 CreatedAt: syntax.DatetimeNow().String(), 100 108 Nonce: nonce[:], 101 109 PublicKey: sharedPubKey[:], 102 - Targets: encSharedKey, 103 110 }, nil 104 111 } 105 112
+1 -5
lexicons/ping.json
··· 9 9 "type": "object", 10 10 "required": ["contents", "createdAt", "nonce", "publicKey"], 11 11 "properties": { 12 - "targets": { 13 - "type": "bytes", 14 - "description": "The one-time private key to decrypt `contents`, which has been encrypted for its recipient(s)." 15 - }, 16 12 "contents": { 17 13 "type": "bytes", 18 - "description": "A message encrypted for a specific recipient, possibly including an encrypted payload. After decryption, this will deserialize as `app.atyo.ping#contents`." 14 + "description": "A message encrypted for specific recipient(s), possibly including an encrypted payload. After decryption, this will deserialize as `app.atyo.ping#contents`." 19 15 }, 20 16 "createdAt": { 21 17 "type": "string",
+1 -1
public/index.html
··· 66 66 <h1>ATYo</h1> 67 67 <form action="/login" id="login-form"> 68 68 <label for="user">Username:</label> 69 - <input type="text" name="username" placeholder="@alice.example.com"><br> 69 + <input type="text" name="usernane" placeholder="@alice.example.com"><br> 70 70 <label for="password"> 71 71 App password (create <a href="https://bsky.app/settings/app-passwords">here</a>): 72 72 </label>