on master help sign in
Add iOS client: Rust/Crux core + SwiftUI shell with crypto, messaging, auth, profiles, QR scanning

- Rust core (crypto.rs, protocol.rs, phonemic.rs, app.rs): full end-to-end messaging, Ed25519 signing, X25519 AEAD, zstd compression, HKDF key derivation - MVU state machine: auth loop with challenge-response binding to server hostname, continuous long-poll (25s), FIFO outbox, delivery acks, profile staleness tracking - Protocol: envelope serialization, plaintext payloads (text, delivery.ack, profile), unknown-type-ignored - Urbit @p syllable encoding/decoding with lookup tables - 27 unit tests (crypto round-trip, protocol, @p, state transitions) - FFI: BoltFFI bridge, facet typegen to Swift types - SwiftUI shell: Core.swift (bridge driver), Effects.swift (HTTP/KeyValue/Time/Keychain), Views/Forms/QRView/PhotoPicker - Keychain identity storage, native QR scanner, photo picker - Verified: builds for aarch64-apple-ios-sim, runs in simulator, authenticates against local ./server, shows 'Online' - Justfile recipes, xcodegen project config, PROTOCOL.md updated to zstd

commit91b7b5824349d6cf66e1d193055c98b52f615c05 authoriko <mail@iko.soy> dateThu 25 Jun 2026, 07:44 (3 months ago) parent3934b22
25 files changed+5440−6
.cargo/config.toml+5 .gitignore+9 Cargo.lock+2376 Cargo.toml+39 Justfile+48 PROTOCOL.md+6−6 apple/SkrepkaApp/Core.swift+74 apple/SkrepkaApp/Effects.swift+158 apple/SkrepkaApp/Forms.swift+156 apple/SkrepkaApp/Info-iOS.plist+39 apple/SkrepkaApp/PhotoPicker.swift+49 apple/SkrepkaApp/QRView.swift+85 apple/SkrepkaApp/SkrepkaApp.swift+12 apple/SkrepkaApp/Views.swift+208 apple/project.yml+41 core/Cargo.toml+58 core/boltffi.toml+18 core/src/app.rs+1070 core/src/bin/codegen.rs+41 core/src/crypto.rs+359 core/src/ffi.rs+62 core/src/lib.rs+10 core/src/model.rs+173 core/src/phonemic.rs+153 core/src/protocol.rs+191
.cargo/config.tomlnew file+5
@@ -0,0 +1,5 @@
1+# Pin the iOS/macOS deployment targets so the C deps (zstd-sys) and rustc agree
2+# on the minimum OS version — otherwise the linker errors on a version mismatch.
3+[env]
4+IPHONEOS_DEPLOYMENT_TARGET = "17.0"
5+MACOSX_DEPLOYMENT_TARGET = "11.0"
.gitignore+9
@@ -9,3 +9,12 @@ client/bin
99client/obj
1010.DS_Store
1111.claude/
12+
13+# Rust core
14+/target
15+
16+# iOS shell (xcodegen project + boltffi/typegen output + build products are generated)
17+apple/SkrepkaApp.xcodeproj
18+apple/build
19+apple/generated
20+apple/DerivedData
Cargo.locknew file+2376
@@ -0,0 +1,2376 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "aead"
7+version = "0.5.2"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
10+dependencies = [
11+ "crypto-common",
12+ "generic-array",
13+]
14+
15+[[package]]
16+name = "aho-corasick"
17+version = "1.1.4"
18+source = "registry+https://github.com/rust-lang/crates.io-index"
19+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
20+dependencies = [
21+ "memchr",
22+]
23+
24+[[package]]
25+name = "allocator-api2"
26+version = "0.2.21"
27+source = "registry+https://github.com/rust-lang/crates.io-index"
28+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
29+
30+[[package]]
31+name = "android_system_properties"
32+version = "0.1.5"
33+source = "registry+https://github.com/rust-lang/crates.io-index"
34+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
35+dependencies = [
36+ "libc",
37+]
38+
39+[[package]]
40+name = "anstream"
41+version = "1.0.0"
42+source = "registry+https://github.com/rust-lang/crates.io-index"
43+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
44+dependencies = [
45+ "anstyle",
46+ "anstyle-parse",
47+ "anstyle-query",
48+ "anstyle-wincon",
49+ "colorchoice",
50+ "is_terminal_polyfill",
51+ "utf8parse",
52+]
53+
54+[[package]]
55+name = "anstyle"
56+version = "1.0.14"
57+source = "registry+https://github.com/rust-lang/crates.io-index"
58+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
59+
60+[[package]]
61+name = "anstyle-parse"
62+version = "1.0.0"
63+source = "registry+https://github.com/rust-lang/crates.io-index"
64+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
65+dependencies = [
66+ "utf8parse",
67+]
68+
69+[[package]]
70+name = "anstyle-query"
71+version = "1.1.5"
72+source = "registry+https://github.com/rust-lang/crates.io-index"
73+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
74+dependencies = [
75+ "windows-sys",
76+]
77+
78+[[package]]
79+name = "anstyle-wincon"
80+version = "3.0.11"
81+source = "registry+https://github.com/rust-lang/crates.io-index"
82+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
83+dependencies = [
84+ "anstyle",
85+ "once_cell_polyfill",
86+ "windows-sys",
87+]
88+
89+[[package]]
90+name = "anyhow"
91+version = "1.0.102"
92+source = "registry+https://github.com/rust-lang/crates.io-index"
93+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
94+
95+[[package]]
96+name = "async-channel"
97+version = "1.9.0"
98+source = "registry+https://github.com/rust-lang/crates.io-index"
99+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
100+dependencies = [
101+ "concurrent-queue",
102+ "event-listener",
103+ "futures-core",
104+]
105+
106+[[package]]
107+name = "async-trait"
108+version = "0.1.89"
109+source = "registry+https://github.com/rust-lang/crates.io-index"
110+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
111+dependencies = [
112+ "proc-macro2",
113+ "quote",
114+ "syn 2.0.118",
115+]
116+
117+[[package]]
118+name = "autocfg"
119+version = "1.5.1"
120+source = "registry+https://github.com/rust-lang/crates.io-index"
121+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
122+
123+[[package]]
124+name = "base64"
125+version = "0.22.1"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
128+
129+[[package]]
130+name = "base64ct"
131+version = "1.8.3"
132+source = "registry+https://github.com/rust-lang/crates.io-index"
133+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
134+
135+[[package]]
136+name = "bincode"
137+version = "1.3.3"
138+source = "registry+https://github.com/rust-lang/crates.io-index"
139+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
140+dependencies = [
141+ "serde",
142+]
143+
144+[[package]]
145+name = "bitflags"
146+version = "2.13.0"
147+source = "registry+https://github.com/rust-lang/crates.io-index"
148+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
149+
150+[[package]]
151+name = "block-buffer"
152+version = "0.10.4"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
155+dependencies = [
156+ "generic-array",
157+]
158+
159+[[package]]
160+name = "boltffi"
161+version = "0.26.1"
162+source = "registry+https://github.com/rust-lang/crates.io-index"
163+checksum = "0d616d59232b93cbb4200fe03171e2c894d27dbee441c92649500bfe9fe66468"
164+dependencies = [
165+ "boltffi_core",
166+ "boltffi_macros",
167+]
168+
169+[[package]]
170+name = "boltffi_ast"
171+version = "0.26.1"
172+source = "registry+https://github.com/rust-lang/crates.io-index"
173+checksum = "79f01d34d075e93e96374eda34928558b88a5287b532fe0eb508400c802a2abc"
174+dependencies = [
175+ "serde",
176+]
177+
178+[[package]]
179+name = "boltffi_binding"
180+version = "0.26.1"
181+source = "registry+https://github.com/rust-lang/crates.io-index"
182+checksum = "9efa5fe74d4642e9152f52cc99facb0fc52836458c6ba6779b7eae1c33ab4a92"
183+dependencies = [
184+ "boltffi_ast",
185+ "serde",
186+]
187+
188+[[package]]
189+name = "boltffi_core"
190+version = "0.26.1"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "06964032c9ae4aa7d395c24cb80985d5e63c392cd6ff20f3ca61bb29a9594191"
193+dependencies = [
194+ "boltffi_macros",
195+ "url",
196+ "uuid",
197+]
198+
199+[[package]]
200+name = "boltffi_ffi_rules"
201+version = "0.26.1"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "070dd06ea89725dc39eb9f96d1ce6fc6822a805d6fec6c7257971f656772abc1"
204+dependencies = [
205+ "serde",
206+ "serde_json",
207+ "syn 2.0.118",
208+ "walkdir",
209+]
210+
211+[[package]]
212+name = "boltffi_macros"
213+version = "0.26.1"
214+source = "registry+https://github.com/rust-lang/crates.io-index"
215+checksum = "92bbe601ce130101d1b6b71ecd6789da4535e94154efcd5df5945090194f74be"
216+dependencies = [
217+ "boltffi_ast",
218+ "boltffi_binding",
219+ "boltffi_ffi_rules",
220+ "proc-macro2",
221+ "quote",
222+ "syn 2.0.118",
223+]
224+
225+[[package]]
226+name = "bumpalo"
227+version = "3.20.3"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
230+
231+[[package]]
232+name = "byteorder"
233+version = "1.5.0"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
236+
237+[[package]]
238+name = "cc"
239+version = "1.2.65"
240+source = "registry+https://github.com/rust-lang/crates.io-index"
241+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
242+dependencies = [
243+ "find-msvc-tools",
244+ "jobserver",
245+ "libc",
246+ "shlex",
247+]
248+
249+[[package]]
250+name = "cfb"
251+version = "0.7.3"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
254+dependencies = [
255+ "byteorder",
256+ "fnv",
257+ "uuid",
258+]
259+
260+[[package]]
261+name = "cfg-if"
262+version = "1.0.4"
263+source = "registry+https://github.com/rust-lang/crates.io-index"
264+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
265+
266+[[package]]
267+name = "chacha20"
268+version = "0.9.1"
269+source = "registry+https://github.com/rust-lang/crates.io-index"
270+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
271+dependencies = [
272+ "cfg-if",
273+ "cipher",
274+ "cpufeatures",
275+]
276+
277+[[package]]
278+name = "chacha20poly1305"
279+version = "0.10.1"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
282+dependencies = [
283+ "aead",
284+ "chacha20",
285+ "cipher",
286+ "poly1305",
287+ "zeroize",
288+]
289+
290+[[package]]
291+name = "chrono"
292+version = "0.4.45"
293+source = "registry+https://github.com/rust-lang/crates.io-index"
294+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
295+dependencies = [
296+ "iana-time-zone",
297+ "num-traits",
298+ "windows-link",
299+]
300+
301+[[package]]
302+name = "cipher"
303+version = "0.4.4"
304+source = "registry+https://github.com/rust-lang/crates.io-index"
305+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
306+dependencies = [
307+ "crypto-common",
308+ "inout",
309+ "zeroize",
310+]
311+
312+[[package]]
313+name = "clap"
314+version = "4.6.1"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
317+dependencies = [
318+ "clap_builder",
319+ "clap_derive",
320+]
321+
322+[[package]]
323+name = "clap_builder"
324+version = "4.6.0"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
327+dependencies = [
328+ "anstream",
329+ "anstyle",
330+ "clap_lex",
331+ "strsim",
332+]
333+
334+[[package]]
335+name = "clap_derive"
336+version = "4.6.1"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
339+dependencies = [
340+ "heck",
341+ "proc-macro2",
342+ "quote",
343+ "syn 2.0.118",
344+]
345+
346+[[package]]
347+name = "clap_lex"
348+version = "1.1.0"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
351+
352+[[package]]
353+name = "colorchoice"
354+version = "1.0.5"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
357+
358+[[package]]
359+name = "concurrent-queue"
360+version = "2.5.0"
361+source = "registry+https://github.com/rust-lang/crates.io-index"
362+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
363+dependencies = [
364+ "crossbeam-utils",
365+]
366+
367+[[package]]
368+name = "const-fnv1a-hash"
369+version = "1.1.0"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
372+
373+[[package]]
374+name = "const-oid"
375+version = "0.9.6"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
378+
379+[[package]]
380+name = "core-foundation-sys"
381+version = "0.8.7"
382+source = "registry+https://github.com/rust-lang/crates.io-index"
383+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
384+
385+[[package]]
386+name = "cpufeatures"
387+version = "0.2.17"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
390+dependencies = [
391+ "libc",
392+]
393+
394+[[package]]
395+name = "crossbeam-channel"
396+version = "0.5.15"
397+source = "registry+https://github.com/rust-lang/crates.io-index"
398+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
399+dependencies = [
400+ "crossbeam-utils",
401+]
402+
403+[[package]]
404+name = "crossbeam-utils"
405+version = "0.8.21"
406+source = "registry+https://github.com/rust-lang/crates.io-index"
407+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
408+
409+[[package]]
410+name = "crux_core"
411+version = "0.19.0"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "159720bb44c1a09e0089459217c8ea6c71a78d868c5bf55d5b41f8781e92af7a"
414+dependencies = [
415+ "anyhow",
416+ "bincode",
417+ "crossbeam-channel",
418+ "crux_macros",
419+ "facet 0.44.5",
420+ "facet_generate",
421+ "futures",
422+ "log",
423+ "serde",
424+ "serde_json",
425+ "slab",
426+ "thiserror",
427+]
428+
429+[[package]]
430+name = "crux_http"
431+version = "0.18.0"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "ef762ab0e77caf6f0fc22ec06491e83967039fe88f3916ec3c8f83b3c52f7e73"
434+dependencies = [
435+ "anyhow",
436+ "async-trait",
437+ "crux_core",
438+ "derive_builder",
439+ "encoding_rs",
440+ "facet 0.44.5",
441+ "facet-generate-attrs",
442+ "futures-util",
443+ "http-types-red-badger-temporary-fork",
444+ "pin-project-lite",
445+ "serde",
446+ "serde_bytes",
447+ "serde_json",
448+ "serde_qs 1.1.2",
449+ "thiserror",
450+ "url",
451+ "web-sys",
452+]
453+
454+[[package]]
455+name = "crux_kv"
456+version = "0.13.0"
457+source = "registry+https://github.com/rust-lang/crates.io-index"
458+checksum = "9ab7d27f05bfbd679c18c5c45a5c15619adf21cf9895a0436728fd3e6a88d783"
459+dependencies = [
460+ "anyhow",
461+ "crux_core",
462+ "facet 0.44.5",
463+ "serde",
464+ "serde_bytes",
465+ "thiserror",
466+]
467+
468+[[package]]
469+name = "crux_macros"
470+version = "0.10.0"
471+source = "registry+https://github.com/rust-lang/crates.io-index"
472+checksum = "952c0b238735c79e917b908c140c7c5f6f6921924ebee21171203fe5fb5fc3f3"
473+dependencies = [
474+ "darling 0.23.0",
475+ "heck",
476+ "proc-macro-error",
477+ "proc-macro2",
478+ "quote",
479+ "syn 2.0.118",
480+]
481+
482+[[package]]
483+name = "crux_time"
484+version = "0.17.0"
485+source = "registry+https://github.com/rust-lang/crates.io-index"
486+checksum = "a2dda3b405568dc1e0fc7a46cc913e6324e918c66fd7cd19d64cc28759d71a58"
487+dependencies = [
488+ "crux_core",
489+ "facet 0.44.5",
490+ "futures",
491+ "serde",
492+ "thiserror",
493+]
494+
495+[[package]]
496+name = "crypto-common"
497+version = "0.1.7"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
500+dependencies = [
501+ "generic-array",
502+ "rand_core 0.6.4",
503+ "typenum",
504+]
505+
506+[[package]]
507+name = "curve25519-dalek"
508+version = "4.1.3"
509+source = "registry+https://github.com/rust-lang/crates.io-index"
510+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
511+dependencies = [
512+ "cfg-if",
513+ "cpufeatures",
514+ "curve25519-dalek-derive",
515+ "digest",
516+ "fiat-crypto",
517+ "rustc_version",
518+ "subtle",
519+ "zeroize",
520+]
521+
522+[[package]]
523+name = "curve25519-dalek-derive"
524+version = "0.1.1"
525+source = "registry+https://github.com/rust-lang/crates.io-index"
526+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
527+dependencies = [
528+ "proc-macro2",
529+ "quote",
530+ "syn 2.0.118",
531+]
532+
533+[[package]]
534+name = "darling"
535+version = "0.20.11"
536+source = "registry+https://github.com/rust-lang/crates.io-index"
537+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
538+dependencies = [
539+ "darling_core 0.20.11",
540+ "darling_macro 0.20.11",
541+]
542+
543+[[package]]
544+name = "darling"
545+version = "0.23.0"
546+source = "registry+https://github.com/rust-lang/crates.io-index"
547+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
548+dependencies = [
549+ "darling_core 0.23.0",
550+ "darling_macro 0.23.0",
551+]
552+
553+[[package]]
554+name = "darling_core"
555+version = "0.20.11"
556+source = "registry+https://github.com/rust-lang/crates.io-index"
557+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
558+dependencies = [
559+ "fnv",
560+ "ident_case",
561+ "proc-macro2",
562+ "quote",
563+ "strsim",
564+ "syn 2.0.118",
565+]
566+
567+[[package]]
568+name = "darling_core"
569+version = "0.23.0"
570+source = "registry+https://github.com/rust-lang/crates.io-index"
571+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
572+dependencies = [
573+ "ident_case",
574+ "proc-macro2",
575+ "quote",
576+ "strsim",
577+ "syn 2.0.118",
578+]
579+
580+[[package]]
581+name = "darling_macro"
582+version = "0.20.11"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
585+dependencies = [
586+ "darling_core 0.20.11",
587+ "quote",
588+ "syn 2.0.118",
589+]
590+
591+[[package]]
592+name = "darling_macro"
593+version = "0.23.0"
594+source = "registry+https://github.com/rust-lang/crates.io-index"
595+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
596+dependencies = [
597+ "darling_core 0.23.0",
598+ "quote",
599+ "syn 2.0.118",
600+]
601+
602+[[package]]
603+name = "der"
604+version = "0.7.10"
605+source = "registry+https://github.com/rust-lang/crates.io-index"
606+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
607+dependencies = [
608+ "const-oid",
609+ "zeroize",
610+]
611+
612+[[package]]
613+name = "derive_builder"
614+version = "0.20.2"
615+source = "registry+https://github.com/rust-lang/crates.io-index"
616+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
617+dependencies = [
618+ "derive_builder_macro",
619+]
620+
621+[[package]]
622+name = "derive_builder_core"
623+version = "0.20.2"
624+source = "registry+https://github.com/rust-lang/crates.io-index"
625+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
626+dependencies = [
627+ "darling 0.20.11",
628+ "proc-macro2",
629+ "quote",
630+ "syn 2.0.118",
631+]
632+
633+[[package]]
634+name = "derive_builder_macro"
635+version = "0.20.2"
636+source = "registry+https://github.com/rust-lang/crates.io-index"
637+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
638+dependencies = [
639+ "derive_builder_core",
640+ "syn 2.0.118",
641+]
642+
643+[[package]]
644+name = "digest"
645+version = "0.10.7"
646+source = "registry+https://github.com/rust-lang/crates.io-index"
647+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
648+dependencies = [
649+ "block-buffer",
650+ "crypto-common",
651+ "subtle",
652+]
653+
654+[[package]]
655+name = "displaydoc"
656+version = "0.2.6"
657+source = "registry+https://github.com/rust-lang/crates.io-index"
658+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
659+dependencies = [
660+ "proc-macro2",
661+ "quote",
662+ "syn 2.0.118",
663+]
664+
665+[[package]]
666+name = "ed25519"
667+version = "2.2.3"
668+source = "registry+https://github.com/rust-lang/crates.io-index"
669+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
670+dependencies = [
671+ "pkcs8",
672+ "signature",
673+]
674+
675+[[package]]
676+name = "ed25519-dalek"
677+version = "2.2.0"
678+source = "registry+https://github.com/rust-lang/crates.io-index"
679+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
680+dependencies = [
681+ "curve25519-dalek",
682+ "ed25519",
683+ "rand_core 0.6.4",
684+ "serde",
685+ "sha2",
686+ "subtle",
687+ "zeroize",
688+]
689+
690+[[package]]
691+name = "encoding_rs"
692+version = "0.8.35"
693+source = "registry+https://github.com/rust-lang/crates.io-index"
694+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
695+dependencies = [
696+ "cfg-if",
697+]
698+
699+[[package]]
700+name = "env_logger"
701+version = "0.10.2"
702+source = "registry+https://github.com/rust-lang/crates.io-index"
703+checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
704+dependencies = [
705+ "humantime",
706+ "is-terminal",
707+ "log",
708+ "regex",
709+ "termcolor",
710+]
711+
712+[[package]]
713+name = "equivalent"
714+version = "1.0.2"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
717+
718+[[package]]
719+name = "event-listener"
720+version = "2.5.3"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
723+
724+[[package]]
725+name = "facet"
726+version = "0.31.8"
727+source = "registry+https://github.com/rust-lang/crates.io-index"
728+checksum = "4d643309d7c46d073b6c51e29901ed9bc3d858dc09aa21f5b32b424491e1bbef"
729+dependencies = [
730+ "facet-core 0.31.8",
731+ "facet-macros 0.31.8",
732+ "static_assertions",
733+]
734+
735+[[package]]
736+name = "facet"
737+version = "0.44.5"
738+source = "registry+https://github.com/rust-lang/crates.io-index"
739+checksum = "c78066af2cc259674a54fef24323f6081ae54a5f3a3fb53b995af7a867041c81"
740+dependencies = [
741+ "autocfg",
742+ "facet-core 0.44.4",
743+ "facet-macros 0.44.4",
744+]
745+
746+[[package]]
747+name = "facet-core"
748+version = "0.31.8"
749+source = "registry+https://github.com/rust-lang/crates.io-index"
750+checksum = "ac27076d75388bffb6c5e5118078dcbb46f2090b35256c45372ff1c1910b1aaf"
751+dependencies = [
752+ "bitflags",
753+ "impls",
754+]
755+
756+[[package]]
757+name = "facet-core"
758+version = "0.44.4"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "04625a816bc27bb757b5f62ec17a228b90c08631f579ee0b36eccd6c426df207"
761+dependencies = [
762+ "autocfg",
763+ "chrono",
764+ "const-fnv1a-hash",
765+ "iddqd",
766+ "impls",
767+]
768+
769+[[package]]
770+name = "facet-generate-attrs"
771+version = "0.17.0"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "3b430b495d59ffcf07dabe103a796d8d3ba809d12bb25a5960b605883835b3de"
774+dependencies = [
775+ "facet 0.44.5",
776+]
777+
778+[[package]]
779+name = "facet-macro-parse"
780+version = "0.44.4"
781+source = "registry+https://github.com/rust-lang/crates.io-index"
782+checksum = "a288cd230608b4d7e89b307f85be2029f265e27dcdc565a2ae5358d0c4e53f57"
783+dependencies = [
784+ "facet-macro-types",
785+ "proc-macro2",
786+ "quote",
787+]
788+
789+[[package]]
790+name = "facet-macro-types"
791+version = "0.44.4"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "9c805b7f1ad4bba14e1dba5c67ef810bf9eef2373364fe3916af1d08a5411296"
794+dependencies = [
795+ "proc-macro2",
796+ "quote",
797+ "unsynn",
798+]
799+
800+[[package]]
801+name = "facet-macros"
802+version = "0.31.8"
803+source = "registry+https://github.com/rust-lang/crates.io-index"
804+checksum = "391fcf43b68a76ba4a494710629a7b6fb430345a5623a6a87f7c263bfadb1643"
805+dependencies = [
806+ "facet-core 0.31.8",
807+ "facet-macros-emit",
808+]
809+
810+[[package]]
811+name = "facet-macros"
812+version = "0.44.4"
813+source = "registry+https://github.com/rust-lang/crates.io-index"
814+checksum = "e0cc46a94a2725e82f2c0095bac16665057c24d7f4f11169a7a879dbcba81d8f"
815+dependencies = [
816+ "facet-macros-impl",
817+]
818+
819+[[package]]
820+name = "facet-macros-emit"
821+version = "0.31.8"
822+source = "registry+https://github.com/rust-lang/crates.io-index"
823+checksum = "e81e536ccafa7d5ef71822c9a67a28a0b29fe0170eaf2d134c36854a3d56ee9a"
824+dependencies = [
825+ "facet-macros-parse",
826+ "quote",
827+]
828+
829+[[package]]
830+name = "facet-macros-impl"
831+version = "0.44.4"
832+source = "registry+https://github.com/rust-lang/crates.io-index"
833+checksum = "1af13fd5dfa728cc48418cf33cbcfc0d5cfdbaaf22a3d0f5229da4b1e5669fa5"
834+dependencies = [
835+ "facet-macro-parse",
836+ "facet-macro-types",
837+ "proc-macro2",
838+ "quote",
839+ "strsim",
840+ "unsynn",
841+]
842+
843+[[package]]
844+name = "facet-macros-parse"
845+version = "0.31.8"
846+source = "registry+https://github.com/rust-lang/crates.io-index"
847+checksum = "8da5d4ded1874033bdef5000712d80d28feefe2abb48bb6f31b6fe59b7e9792f"
848+dependencies = [
849+ "proc-macro2",
850+ "quote",
851+ "unsynn",
852+]
853+
854+[[package]]
855+name = "facet_generate"
856+version = "0.17.1"
857+source = "registry+https://github.com/rust-lang/crates.io-index"
858+checksum = "831d4a105b7e0abce52910f544ffa2698064b1d4864d381672137f804548a45a"
859+dependencies = [
860+ "derive_builder",
861+ "facet 0.44.5",
862+ "facet-generate-attrs",
863+ "heck",
864+ "include_dir",
865+ "indent",
866+ "indoc",
867+ "regex",
868+ "serde",
869+ "serde_json",
870+ "textwrap",
871+ "thiserror",
872+]
873+
874+[[package]]
875+name = "fastrand"
876+version = "2.4.1"
877+source = "registry+https://github.com/rust-lang/crates.io-index"
878+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
879+
880+[[package]]
881+name = "fiat-crypto"
882+version = "0.2.9"
883+source = "registry+https://github.com/rust-lang/crates.io-index"
884+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
885+
886+[[package]]
887+name = "find-msvc-tools"
888+version = "0.1.9"
889+source = "registry+https://github.com/rust-lang/crates.io-index"
890+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
891+
892+[[package]]
893+name = "fnv"
894+version = "1.0.7"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
897+
898+[[package]]
899+name = "foldhash"
900+version = "0.2.0"
901+source = "registry+https://github.com/rust-lang/crates.io-index"
902+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
903+
904+[[package]]
905+name = "form_urlencoded"
906+version = "1.2.2"
907+source = "registry+https://github.com/rust-lang/crates.io-index"
908+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
909+dependencies = [
910+ "percent-encoding",
911+]
912+
913+[[package]]
914+name = "futures"
915+version = "0.3.32"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
918+dependencies = [
919+ "futures-channel",
920+ "futures-core",
921+ "futures-executor",
922+ "futures-io",
923+ "futures-sink",
924+ "futures-task",
925+ "futures-util",
926+]
927+
928+[[package]]
929+name = "futures-channel"
930+version = "0.3.32"
931+source = "registry+https://github.com/rust-lang/crates.io-index"
932+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
933+dependencies = [
934+ "futures-core",
935+ "futures-sink",
936+]
937+
938+[[package]]
939+name = "futures-core"
940+version = "0.3.32"
941+source = "registry+https://github.com/rust-lang/crates.io-index"
942+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
943+
944+[[package]]
945+name = "futures-executor"
946+version = "0.3.32"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
949+dependencies = [
950+ "futures-core",
951+ "futures-task",
952+ "futures-util",
953+]
954+
955+[[package]]
956+name = "futures-io"
957+version = "0.3.32"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
960+
961+[[package]]
962+name = "futures-lite"
963+version = "2.6.1"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
966+dependencies = [
967+ "fastrand",
968+ "futures-core",
969+ "futures-io",
970+ "parking",
971+ "pin-project-lite",
972+]
973+
974+[[package]]
975+name = "futures-macro"
976+version = "0.3.32"
977+source = "registry+https://github.com/rust-lang/crates.io-index"
978+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
979+dependencies = [
980+ "proc-macro2",
981+ "quote",
982+ "syn 2.0.118",
983+]
984+
985+[[package]]
986+name = "futures-sink"
987+version = "0.3.32"
988+source = "registry+https://github.com/rust-lang/crates.io-index"
989+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
990+
991+[[package]]
992+name = "futures-task"
993+version = "0.3.32"
994+source = "registry+https://github.com/rust-lang/crates.io-index"
995+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
996+
997+[[package]]
998+name = "futures-util"
999+version = "0.3.32"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1002+dependencies = [
1003+ "futures-channel",
1004+ "futures-core",
1005+ "futures-io",
1006+ "futures-macro",
1007+ "futures-sink",
1008+ "futures-task",
1009+ "memchr",
1010+ "pin-project-lite",
1011+ "slab",
1012+]
1013+
1014+[[package]]
1015+name = "generic-array"
1016+version = "0.14.7"
1017+source = "registry+https://github.com/rust-lang/crates.io-index"
1018+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1019+dependencies = [
1020+ "typenum",
1021+ "version_check",
1022+]
1023+
1024+[[package]]
1025+name = "getrandom"
1026+version = "0.2.17"
1027+source = "registry+https://github.com/rust-lang/crates.io-index"
1028+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1029+dependencies = [
1030+ "cfg-if",
1031+ "libc",
1032+ "wasi",
1033+]
1034+
1035+[[package]]
1036+name = "getrandom"
1037+version = "0.3.4"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1040+dependencies = [
1041+ "cfg-if",
1042+ "libc",
1043+ "r-efi 5.3.0",
1044+ "wasip2",
1045+]
1046+
1047+[[package]]
1048+name = "getrandom"
1049+version = "0.4.3"
1050+source = "registry+https://github.com/rust-lang/crates.io-index"
1051+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1052+dependencies = [
1053+ "cfg-if",
1054+ "libc",
1055+ "r-efi 6.0.0",
1056+]
1057+
1058+[[package]]
1059+name = "hashbrown"
1060+version = "0.16.1"
1061+source = "registry+https://github.com/rust-lang/crates.io-index"
1062+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1063+dependencies = [
1064+ "allocator-api2",
1065+]
1066+
1067+[[package]]
1068+name = "heck"
1069+version = "0.5.0"
1070+source = "registry+https://github.com/rust-lang/crates.io-index"
1071+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1072+
1073+[[package]]
1074+name = "hermit-abi"
1075+version = "0.5.2"
1076+source = "registry+https://github.com/rust-lang/crates.io-index"
1077+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
1078+
1079+[[package]]
1080+name = "hex"
1081+version = "0.4.3"
1082+source = "registry+https://github.com/rust-lang/crates.io-index"
1083+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1084+
1085+[[package]]
1086+name = "hkdf"
1087+version = "0.12.4"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1090+dependencies = [
1091+ "hmac",
1092+]
1093+
1094+[[package]]
1095+name = "hmac"
1096+version = "0.12.1"
1097+source = "registry+https://github.com/rust-lang/crates.io-index"
1098+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1099+dependencies = [
1100+ "digest",
1101+]
1102+
1103+[[package]]
1104+name = "http-types-red-badger-temporary-fork"
1105+version = "6.0.0"
1106+source = "registry+https://github.com/rust-lang/crates.io-index"
1107+checksum = "3042c4259fc659f9fa8aea1d71c4ae4201c58a0adddd3406910aed0870c68382"
1108+dependencies = [
1109+ "anyhow",
1110+ "async-channel",
1111+ "base64",
1112+ "facet 0.31.8",
1113+ "futures-lite",
1114+ "infer",
1115+ "pin-project-lite",
1116+ "rand",
1117+ "serde",
1118+ "serde_json",
1119+ "serde_qs 0.15.0",
1120+ "serde_urlencoded",
1121+ "url",
1122+]
1123+
1124+[[package]]
1125+name = "humantime"
1126+version = "2.3.0"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
1129+
1130+[[package]]
1131+name = "iana-time-zone"
1132+version = "0.1.65"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1135+dependencies = [
1136+ "android_system_properties",
1137+ "core-foundation-sys",
1138+ "iana-time-zone-haiku",
1139+ "js-sys",
1140+ "log",
1141+ "wasm-bindgen",
1142+ "windows-core",
1143+]
1144+
1145+[[package]]
1146+name = "iana-time-zone-haiku"
1147+version = "0.1.2"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1150+dependencies = [
1151+ "cc",
1152+]
1153+
1154+[[package]]
1155+name = "icu_collections"
1156+version = "2.2.0"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
1159+dependencies = [
1160+ "displaydoc",
1161+ "potential_utf",
1162+ "utf8_iter",
1163+ "yoke",
1164+ "zerofrom",
1165+ "zerovec",
1166+]
1167+
1168+[[package]]
1169+name = "icu_locale_core"
1170+version = "2.2.0"
1171+source = "registry+https://github.com/rust-lang/crates.io-index"
1172+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
1173+dependencies = [
1174+ "displaydoc",
1175+ "litemap",
1176+ "tinystr",
1177+ "writeable",
1178+ "zerovec",
1179+]
1180+
1181+[[package]]
1182+name = "icu_normalizer"
1183+version = "2.2.0"
1184+source = "registry+https://github.com/rust-lang/crates.io-index"
1185+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
1186+dependencies = [
1187+ "icu_collections",
1188+ "icu_normalizer_data",
1189+ "icu_properties",
1190+ "icu_provider",
1191+ "smallvec",
1192+ "zerovec",
1193+]
1194+
1195+[[package]]
1196+name = "icu_normalizer_data"
1197+version = "2.2.0"
1198+source = "registry+https://github.com/rust-lang/crates.io-index"
1199+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
1200+
1201+[[package]]
1202+name = "icu_properties"
1203+version = "2.2.0"
1204+source = "registry+https://github.com/rust-lang/crates.io-index"
1205+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
1206+dependencies = [
1207+ "icu_collections",
1208+ "icu_locale_core",
1209+ "icu_properties_data",
1210+ "icu_provider",
1211+ "zerotrie",
1212+ "zerovec",
1213+]
1214+
1215+[[package]]
1216+name = "icu_properties_data"
1217+version = "2.2.0"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
1220+
1221+[[package]]
1222+name = "icu_provider"
1223+version = "2.2.0"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
1226+dependencies = [
1227+ "displaydoc",
1228+ "icu_locale_core",
1229+ "writeable",
1230+ "yoke",
1231+ "zerofrom",
1232+ "zerotrie",
1233+ "zerovec",
1234+]
1235+
1236+[[package]]
1237+name = "iddqd"
1238+version = "0.3.18"
1239+source = "registry+https://github.com/rust-lang/crates.io-index"
1240+checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca"
1241+dependencies = [
1242+ "allocator-api2",
1243+ "equivalent",
1244+ "foldhash",
1245+ "hashbrown",
1246+ "rustc-hash",
1247+]
1248+
1249+[[package]]
1250+name = "ident_case"
1251+version = "1.0.1"
1252+source = "registry+https://github.com/rust-lang/crates.io-index"
1253+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1254+
1255+[[package]]
1256+name = "idna"
1257+version = "1.1.0"
1258+source = "registry+https://github.com/rust-lang/crates.io-index"
1259+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1260+dependencies = [
1261+ "idna_adapter",
1262+ "smallvec",
1263+ "utf8_iter",
1264+]
1265+
1266+[[package]]
1267+name = "idna_adapter"
1268+version = "1.2.2"
1269+source = "registry+https://github.com/rust-lang/crates.io-index"
1270+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1271+dependencies = [
1272+ "icu_normalizer",
1273+ "icu_properties",
1274+]
1275+
1276+[[package]]
1277+name = "impls"
1278+version = "1.0.3"
1279+source = "registry+https://github.com/rust-lang/crates.io-index"
1280+checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
1281+
1282+[[package]]
1283+name = "include_dir"
1284+version = "0.7.4"
1285+source = "registry+https://github.com/rust-lang/crates.io-index"
1286+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1287+dependencies = [
1288+ "include_dir_macros",
1289+]
1290+
1291+[[package]]
1292+name = "include_dir_macros"
1293+version = "0.7.4"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1296+dependencies = [
1297+ "proc-macro2",
1298+ "quote",
1299+]
1300+
1301+[[package]]
1302+name = "indent"
1303+version = "0.1.1"
1304+source = "registry+https://github.com/rust-lang/crates.io-index"
1305+checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
1306+
1307+[[package]]
1308+name = "indoc"
1309+version = "2.0.7"
1310+source = "registry+https://github.com/rust-lang/crates.io-index"
1311+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1312+dependencies = [
1313+ "rustversion",
1314+]
1315+
1316+[[package]]
1317+name = "infer"
1318+version = "0.19.0"
1319+source = "registry+https://github.com/rust-lang/crates.io-index"
1320+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
1321+dependencies = [
1322+ "cfb",
1323+]
1324+
1325+[[package]]
1326+name = "inout"
1327+version = "0.1.4"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1330+dependencies = [
1331+ "generic-array",
1332+]
1333+
1334+[[package]]
1335+name = "is-terminal"
1336+version = "0.4.17"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
1339+dependencies = [
1340+ "hermit-abi",
1341+ "libc",
1342+ "windows-sys",
1343+]
1344+
1345+[[package]]
1346+name = "is_terminal_polyfill"
1347+version = "1.70.2"
1348+source = "registry+https://github.com/rust-lang/crates.io-index"
1349+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1350+
1351+[[package]]
1352+name = "itoa"
1353+version = "1.0.18"
1354+source = "registry+https://github.com/rust-lang/crates.io-index"
1355+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1356+
1357+[[package]]
1358+name = "jobserver"
1359+version = "0.1.34"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1362+dependencies = [
1363+ "getrandom 0.3.4",
1364+ "libc",
1365+]
1366+
1367+[[package]]
1368+name = "js-sys"
1369+version = "0.3.103"
1370+source = "registry+https://github.com/rust-lang/crates.io-index"
1371+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
1372+dependencies = [
1373+ "cfg-if",
1374+ "futures-util",
1375+ "wasm-bindgen",
1376+]
1377+
1378+[[package]]
1379+name = "libc"
1380+version = "0.2.186"
1381+source = "registry+https://github.com/rust-lang/crates.io-index"
1382+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
1383+
1384+[[package]]
1385+name = "litemap"
1386+version = "0.8.2"
1387+source = "registry+https://github.com/rust-lang/crates.io-index"
1388+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
1389+
1390+[[package]]
1391+name = "log"
1392+version = "0.4.33"
1393+source = "registry+https://github.com/rust-lang/crates.io-index"
1394+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1395+
1396+[[package]]
1397+name = "memchr"
1398+version = "2.8.2"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
1401+
1402+[[package]]
1403+name = "mutants"
1404+version = "0.0.3"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
1407+
1408+[[package]]
1409+name = "num-traits"
1410+version = "0.2.19"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1413+dependencies = [
1414+ "autocfg",
1415+]
1416+
1417+[[package]]
1418+name = "once_cell"
1419+version = "1.21.4"
1420+source = "registry+https://github.com/rust-lang/crates.io-index"
1421+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1422+
1423+[[package]]
1424+name = "once_cell_polyfill"
1425+version = "1.70.2"
1426+source = "registry+https://github.com/rust-lang/crates.io-index"
1427+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1428+
1429+[[package]]
1430+name = "opaque-debug"
1431+version = "0.3.1"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1434+
1435+[[package]]
1436+name = "parking"
1437+version = "2.2.1"
1438+source = "registry+https://github.com/rust-lang/crates.io-index"
1439+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1440+
1441+[[package]]
1442+name = "percent-encoding"
1443+version = "2.3.2"
1444+source = "registry+https://github.com/rust-lang/crates.io-index"
1445+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1446+
1447+[[package]]
1448+name = "pin-project-lite"
1449+version = "0.2.17"
1450+source = "registry+https://github.com/rust-lang/crates.io-index"
1451+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1452+
1453+[[package]]
1454+name = "pkcs8"
1455+version = "0.10.2"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1458+dependencies = [
1459+ "der",
1460+ "spki",
1461+]
1462+
1463+[[package]]
1464+name = "pkg-config"
1465+version = "0.3.33"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1468+
1469+[[package]]
1470+name = "poly1305"
1471+version = "0.8.0"
1472+source = "registry+https://github.com/rust-lang/crates.io-index"
1473+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
1474+dependencies = [
1475+ "cpufeatures",
1476+ "opaque-debug",
1477+ "universal-hash",
1478+]
1479+
1480+[[package]]
1481+name = "potential_utf"
1482+version = "0.1.5"
1483+source = "registry+https://github.com/rust-lang/crates.io-index"
1484+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
1485+dependencies = [
1486+ "zerovec",
1487+]
1488+
1489+[[package]]
1490+name = "ppv-lite86"
1491+version = "0.2.21"
1492+source = "registry+https://github.com/rust-lang/crates.io-index"
1493+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1494+dependencies = [
1495+ "zerocopy",
1496+]
1497+
1498+[[package]]
1499+name = "pretty_env_logger"
1500+version = "0.5.0"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
1503+dependencies = [
1504+ "env_logger",
1505+ "log",
1506+]
1507+
1508+[[package]]
1509+name = "proc-macro-error"
1510+version = "1.0.4"
1511+source = "registry+https://github.com/rust-lang/crates.io-index"
1512+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
1513+dependencies = [
1514+ "proc-macro-error-attr",
1515+ "proc-macro2",
1516+ "quote",
1517+ "syn 1.0.109",
1518+ "version_check",
1519+]
1520+
1521+[[package]]
1522+name = "proc-macro-error-attr"
1523+version = "1.0.4"
1524+source = "registry+https://github.com/rust-lang/crates.io-index"
1525+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
1526+dependencies = [
1527+ "proc-macro2",
1528+ "quote",
1529+ "version_check",
1530+]
1531+
1532+[[package]]
1533+name = "proc-macro2"
1534+version = "1.0.106"
1535+source = "registry+https://github.com/rust-lang/crates.io-index"
1536+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1537+dependencies = [
1538+ "unicode-ident",
1539+]
1540+
1541+[[package]]
1542+name = "quote"
1543+version = "1.0.46"
1544+source = "registry+https://github.com/rust-lang/crates.io-index"
1545+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
1546+dependencies = [
1547+ "proc-macro2",
1548+]
1549+
1550+[[package]]
1551+name = "r-efi"
1552+version = "5.3.0"
1553+source = "registry+https://github.com/rust-lang/crates.io-index"
1554+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1555+
1556+[[package]]
1557+name = "r-efi"
1558+version = "6.0.0"
1559+source = "registry+https://github.com/rust-lang/crates.io-index"
1560+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1561+
1562+[[package]]
1563+name = "rand"
1564+version = "0.9.4"
1565+source = "registry+https://github.com/rust-lang/crates.io-index"
1566+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1567+dependencies = [
1568+ "rand_chacha 0.9.0",
1569+ "rand_core 0.9.5",
1570+]
1571+
1572+[[package]]
1573+name = "rand_chacha"
1574+version = "0.3.1"
1575+source = "registry+https://github.com/rust-lang/crates.io-index"
1576+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1577+dependencies = [
1578+ "ppv-lite86",
1579+ "rand_core 0.6.4",
1580+]
1581+
1582+[[package]]
1583+name = "rand_chacha"
1584+version = "0.9.0"
1585+source = "registry+https://github.com/rust-lang/crates.io-index"
1586+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1587+dependencies = [
1588+ "ppv-lite86",
1589+ "rand_core 0.9.5",
1590+]
1591+
1592+[[package]]
1593+name = "rand_core"
1594+version = "0.6.4"
1595+source = "registry+https://github.com/rust-lang/crates.io-index"
1596+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1597+dependencies = [
1598+ "getrandom 0.2.17",
1599+]
1600+
1601+[[package]]
1602+name = "rand_core"
1603+version = "0.9.5"
1604+source = "registry+https://github.com/rust-lang/crates.io-index"
1605+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1606+dependencies = [
1607+ "getrandom 0.3.4",
1608+]
1609+
1610+[[package]]
1611+name = "regex"
1612+version = "1.12.4"
1613+source = "registry+https://github.com/rust-lang/crates.io-index"
1614+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1615+dependencies = [
1616+ "aho-corasick",
1617+ "memchr",
1618+ "regex-automata",
1619+ "regex-syntax",
1620+]
1621+
1622+[[package]]
1623+name = "regex-automata"
1624+version = "0.4.14"
1625+source = "registry+https://github.com/rust-lang/crates.io-index"
1626+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1627+dependencies = [
1628+ "aho-corasick",
1629+ "memchr",
1630+ "regex-syntax",
1631+]
1632+
1633+[[package]]
1634+name = "regex-syntax"
1635+version = "0.8.11"
1636+source = "registry+https://github.com/rust-lang/crates.io-index"
1637+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1638+
1639+[[package]]
1640+name = "rustc-hash"
1641+version = "2.1.2"
1642+source = "registry+https://github.com/rust-lang/crates.io-index"
1643+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1644+
1645+[[package]]
1646+name = "rustc_version"
1647+version = "0.4.1"
1648+source = "registry+https://github.com/rust-lang/crates.io-index"
1649+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1650+dependencies = [
1651+ "semver",
1652+]
1653+
1654+[[package]]
1655+name = "rustversion"
1656+version = "1.0.22"
1657+source = "registry+https://github.com/rust-lang/crates.io-index"
1658+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1659+
1660+[[package]]
1661+name = "ryu"
1662+version = "1.0.23"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1665+
1666+[[package]]
1667+name = "same-file"
1668+version = "1.0.6"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1671+dependencies = [
1672+ "winapi-util",
1673+]
1674+
1675+[[package]]
1676+name = "semver"
1677+version = "1.0.28"
1678+source = "registry+https://github.com/rust-lang/crates.io-index"
1679+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1680+
1681+[[package]]
1682+name = "serde"
1683+version = "1.0.228"
1684+source = "registry+https://github.com/rust-lang/crates.io-index"
1685+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1686+dependencies = [
1687+ "serde_core",
1688+ "serde_derive",
1689+]
1690+
1691+[[package]]
1692+name = "serde_bytes"
1693+version = "0.11.19"
1694+source = "registry+https://github.com/rust-lang/crates.io-index"
1695+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
1696+dependencies = [
1697+ "serde",
1698+ "serde_core",
1699+]
1700+
1701+[[package]]
1702+name = "serde_core"
1703+version = "1.0.228"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1706+dependencies = [
1707+ "serde_derive",
1708+]
1709+
1710+[[package]]
1711+name = "serde_derive"
1712+version = "1.0.228"
1713+source = "registry+https://github.com/rust-lang/crates.io-index"
1714+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1715+dependencies = [
1716+ "proc-macro2",
1717+ "quote",
1718+ "syn 2.0.118",
1719+]
1720+
1721+[[package]]
1722+name = "serde_json"
1723+version = "1.0.150"
1724+source = "registry+https://github.com/rust-lang/crates.io-index"
1725+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1726+dependencies = [
1727+ "itoa",
1728+ "memchr",
1729+ "serde",
1730+ "serde_core",
1731+ "zmij",
1732+]
1733+
1734+[[package]]
1735+name = "serde_qs"
1736+version = "0.15.0"
1737+source = "registry+https://github.com/rust-lang/crates.io-index"
1738+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
1739+dependencies = [
1740+ "percent-encoding",
1741+ "serde",
1742+ "thiserror",
1743+]
1744+
1745+[[package]]
1746+name = "serde_qs"
1747+version = "1.1.2"
1748+source = "registry+https://github.com/rust-lang/crates.io-index"
1749+checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
1750+dependencies = [
1751+ "itoa",
1752+ "percent-encoding",
1753+ "ryu",
1754+ "serde",
1755+]
1756+
1757+[[package]]
1758+name = "serde_urlencoded"
1759+version = "0.7.1"
1760+source = "registry+https://github.com/rust-lang/crates.io-index"
1761+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1762+dependencies = [
1763+ "form_urlencoded",
1764+ "itoa",
1765+ "ryu",
1766+ "serde",
1767+]
1768+
1769+[[package]]
1770+name = "sha2"
1771+version = "0.10.9"
1772+source = "registry+https://github.com/rust-lang/crates.io-index"
1773+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1774+dependencies = [
1775+ "cfg-if",
1776+ "cpufeatures",
1777+ "digest",
1778+]
1779+
1780+[[package]]
1781+name = "shlex"
1782+version = "2.0.1"
1783+source = "registry+https://github.com/rust-lang/crates.io-index"
1784+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1785+
1786+[[package]]
1787+name = "signature"
1788+version = "2.2.0"
1789+source = "registry+https://github.com/rust-lang/crates.io-index"
1790+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1791+dependencies = [
1792+ "rand_core 0.6.4",
1793+]
1794+
1795+[[package]]
1796+name = "skrepka_core"
1797+version = "0.1.0"
1798+dependencies = [
1799+ "anyhow",
1800+ "base64",
1801+ "boltffi",
1802+ "chacha20poly1305",
1803+ "clap",
1804+ "crux_core",
1805+ "crux_http",
1806+ "crux_kv",
1807+ "crux_time",
1808+ "curve25519-dalek",
1809+ "ed25519-dalek",
1810+ "facet 0.44.5",
1811+ "hex",
1812+ "hkdf",
1813+ "log",
1814+ "pretty_env_logger",
1815+ "rand_chacha 0.3.1",
1816+ "rand_core 0.6.4",
1817+ "serde",
1818+ "serde_json",
1819+ "sha2",
1820+ "uuid",
1821+ "x25519-dalek",
1822+ "zstd",
1823+]
1824+
1825+[[package]]
1826+name = "slab"
1827+version = "0.4.12"
1828+source = "registry+https://github.com/rust-lang/crates.io-index"
1829+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1830+
1831+[[package]]
1832+name = "smallvec"
1833+version = "1.15.2"
1834+source = "registry+https://github.com/rust-lang/crates.io-index"
1835+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1836+
1837+[[package]]
1838+name = "smawk"
1839+version = "0.3.3"
1840+source = "registry+https://github.com/rust-lang/crates.io-index"
1841+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1842+
1843+[[package]]
1844+name = "spki"
1845+version = "0.7.3"
1846+source = "registry+https://github.com/rust-lang/crates.io-index"
1847+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1848+dependencies = [
1849+ "base64ct",
1850+ "der",
1851+]
1852+
1853+[[package]]
1854+name = "stable_deref_trait"
1855+version = "1.2.1"
1856+source = "registry+https://github.com/rust-lang/crates.io-index"
1857+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1858+
1859+[[package]]
1860+name = "static_assertions"
1861+version = "1.1.0"
1862+source = "registry+https://github.com/rust-lang/crates.io-index"
1863+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1864+
1865+[[package]]
1866+name = "strsim"
1867+version = "0.11.1"
1868+source = "registry+https://github.com/rust-lang/crates.io-index"
1869+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1870+
1871+[[package]]
1872+name = "subtle"
1873+version = "2.6.1"
1874+source = "registry+https://github.com/rust-lang/crates.io-index"
1875+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1876+
1877+[[package]]
1878+name = "syn"
1879+version = "1.0.109"
1880+source = "registry+https://github.com/rust-lang/crates.io-index"
1881+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1882+dependencies = [
1883+ "proc-macro2",
1884+ "unicode-ident",
1885+]
1886+
1887+[[package]]
1888+name = "syn"
1889+version = "2.0.118"
1890+source = "registry+https://github.com/rust-lang/crates.io-index"
1891+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1892+dependencies = [
1893+ "proc-macro2",
1894+ "quote",
1895+ "unicode-ident",
1896+]
1897+
1898+[[package]]
1899+name = "synstructure"
1900+version = "0.13.2"
1901+source = "registry+https://github.com/rust-lang/crates.io-index"
1902+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1903+dependencies = [
1904+ "proc-macro2",
1905+ "quote",
1906+ "syn 2.0.118",
1907+]
1908+
1909+[[package]]
1910+name = "termcolor"
1911+version = "1.4.1"
1912+source = "registry+https://github.com/rust-lang/crates.io-index"
1913+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
1914+dependencies = [
1915+ "winapi-util",
1916+]
1917+
1918+[[package]]
1919+name = "textwrap"
1920+version = "0.16.2"
1921+source = "registry+https://github.com/rust-lang/crates.io-index"
1922+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1923+dependencies = [
1924+ "smawk",
1925+ "unicode-linebreak",
1926+ "unicode-width",
1927+]
1928+
1929+[[package]]
1930+name = "thiserror"
1931+version = "2.0.18"
1932+source = "registry+https://github.com/rust-lang/crates.io-index"
1933+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1934+dependencies = [
1935+ "thiserror-impl",
1936+]
1937+
1938+[[package]]
1939+name = "thiserror-impl"
1940+version = "2.0.18"
1941+source = "registry+https://github.com/rust-lang/crates.io-index"
1942+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1943+dependencies = [
1944+ "proc-macro2",
1945+ "quote",
1946+ "syn 2.0.118",
1947+]
1948+
1949+[[package]]
1950+name = "tinystr"
1951+version = "0.8.3"
1952+source = "registry+https://github.com/rust-lang/crates.io-index"
1953+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1954+dependencies = [
1955+ "displaydoc",
1956+ "zerovec",
1957+]
1958+
1959+[[package]]
1960+name = "typenum"
1961+version = "1.20.1"
1962+source = "registry+https://github.com/rust-lang/crates.io-index"
1963+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1964+
1965+[[package]]
1966+name = "unicode-ident"
1967+version = "1.0.24"
1968+source = "registry+https://github.com/rust-lang/crates.io-index"
1969+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1970+
1971+[[package]]
1972+name = "unicode-linebreak"
1973+version = "0.1.5"
1974+source = "registry+https://github.com/rust-lang/crates.io-index"
1975+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1976+
1977+[[package]]
1978+name = "unicode-width"
1979+version = "0.2.2"
1980+source = "registry+https://github.com/rust-lang/crates.io-index"
1981+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1982+
1983+[[package]]
1984+name = "universal-hash"
1985+version = "0.5.1"
1986+source = "registry+https://github.com/rust-lang/crates.io-index"
1987+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
1988+dependencies = [
1989+ "crypto-common",
1990+ "subtle",
1991+]
1992+
1993+[[package]]
1994+name = "unsynn"
1995+version = "0.3.0"
1996+source = "registry+https://github.com/rust-lang/crates.io-index"
1997+checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
1998+dependencies = [
1999+ "mutants",
2000+ "proc-macro2",
2001+ "rustc-hash",
2002+]
2003+
2004+[[package]]
2005+name = "url"
2006+version = "2.5.8"
2007+source = "registry+https://github.com/rust-lang/crates.io-index"
2008+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2009+dependencies = [
2010+ "form_urlencoded",
2011+ "idna",
2012+ "percent-encoding",
2013+ "serde",
2014+ "serde_derive",
2015+]
2016+
2017+[[package]]
2018+name = "utf8_iter"
2019+version = "1.0.4"
2020+source = "registry+https://github.com/rust-lang/crates.io-index"
2021+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2022+
2023+[[package]]
2024+name = "utf8parse"
2025+version = "0.2.2"
2026+source = "registry+https://github.com/rust-lang/crates.io-index"
2027+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2028+
2029+[[package]]
2030+name = "uuid"
2031+version = "1.23.4"
2032+source = "registry+https://github.com/rust-lang/crates.io-index"
2033+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
2034+dependencies = [
2035+ "getrandom 0.4.3",
2036+ "js-sys",
2037+ "wasm-bindgen",
2038+]
2039+
2040+[[package]]
2041+name = "version_check"
2042+version = "0.9.5"
2043+source = "registry+https://github.com/rust-lang/crates.io-index"
2044+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2045+
2046+[[package]]
2047+name = "walkdir"
2048+version = "2.5.0"
2049+source = "registry+https://github.com/rust-lang/crates.io-index"
2050+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2051+dependencies = [
2052+ "same-file",
2053+ "winapi-util",
2054+]
2055+
2056+[[package]]
2057+name = "wasi"
2058+version = "0.11.1+wasi-snapshot-preview1"
2059+source = "registry+https://github.com/rust-lang/crates.io-index"
2060+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2061+
2062+[[package]]
2063+name = "wasip2"
2064+version = "1.0.4+wasi-0.2.12"
2065+source = "registry+https://github.com/rust-lang/crates.io-index"
2066+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2067+dependencies = [
2068+ "wit-bindgen",
2069+]
2070+
2071+[[package]]
2072+name = "wasm-bindgen"
2073+version = "0.2.126"
2074+source = "registry+https://github.com/rust-lang/crates.io-index"
2075+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
2076+dependencies = [
2077+ "cfg-if",
2078+ "once_cell",
2079+ "rustversion",
2080+ "wasm-bindgen-macro",
2081+ "wasm-bindgen-shared",
2082+]
2083+
2084+[[package]]
2085+name = "wasm-bindgen-macro"
2086+version = "0.2.126"
2087+source = "registry+https://github.com/rust-lang/crates.io-index"
2088+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
2089+dependencies = [
2090+ "quote",
2091+ "wasm-bindgen-macro-support",
2092+]
2093+
2094+[[package]]
2095+name = "wasm-bindgen-macro-support"
2096+version = "0.2.126"
2097+source = "registry+https://github.com/rust-lang/crates.io-index"
2098+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
2099+dependencies = [
2100+ "bumpalo",
2101+ "proc-macro2",
2102+ "quote",
2103+ "syn 2.0.118",
2104+ "wasm-bindgen-shared",
2105+]
2106+
2107+[[package]]
2108+name = "wasm-bindgen-shared"
2109+version = "0.2.126"
2110+source = "registry+https://github.com/rust-lang/crates.io-index"
2111+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
2112+dependencies = [
2113+ "unicode-ident",
2114+]
2115+
2116+[[package]]
2117+name = "web-sys"
2118+version = "0.3.103"
2119+source = "registry+https://github.com/rust-lang/crates.io-index"
2120+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
2121+dependencies = [
2122+ "js-sys",
2123+ "wasm-bindgen",
2124+]
2125+
2126+[[package]]
2127+name = "winapi-util"
2128+version = "0.1.11"
2129+source = "registry+https://github.com/rust-lang/crates.io-index"
2130+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2131+dependencies = [
2132+ "windows-sys",
2133+]
2134+
2135+[[package]]
2136+name = "windows-core"
2137+version = "0.62.2"
2138+source = "registry+https://github.com/rust-lang/crates.io-index"
2139+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2140+dependencies = [
2141+ "windows-implement",
2142+ "windows-interface",
2143+ "windows-link",
2144+ "windows-result",
2145+ "windows-strings",
2146+]
2147+
2148+[[package]]
2149+name = "windows-implement"
2150+version = "0.60.2"
2151+source = "registry+https://github.com/rust-lang/crates.io-index"
2152+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2153+dependencies = [
2154+ "proc-macro2",
2155+ "quote",
2156+ "syn 2.0.118",
2157+]
2158+
2159+[[package]]
2160+name = "windows-interface"
2161+version = "0.59.3"
2162+source = "registry+https://github.com/rust-lang/crates.io-index"
2163+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2164+dependencies = [
2165+ "proc-macro2",
2166+ "quote",
2167+ "syn 2.0.118",
2168+]
2169+
2170+[[package]]
2171+name = "windows-link"
2172+version = "0.2.1"
2173+source = "registry+https://github.com/rust-lang/crates.io-index"
2174+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2175+
2176+[[package]]
2177+name = "windows-result"
2178+version = "0.4.1"
2179+source = "registry+https://github.com/rust-lang/crates.io-index"
2180+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2181+dependencies = [
2182+ "windows-link",
2183+]
2184+
2185+[[package]]
2186+name = "windows-strings"
2187+version = "0.5.1"
2188+source = "registry+https://github.com/rust-lang/crates.io-index"
2189+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2190+dependencies = [
2191+ "windows-link",
2192+]
2193+
2194+[[package]]
2195+name = "windows-sys"
2196+version = "0.61.2"
2197+source = "registry+https://github.com/rust-lang/crates.io-index"
2198+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2199+dependencies = [
2200+ "windows-link",
2201+]
2202+
2203+[[package]]
2204+name = "wit-bindgen"
2205+version = "0.57.1"
2206+source = "registry+https://github.com/rust-lang/crates.io-index"
2207+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2208+
2209+[[package]]
2210+name = "writeable"
2211+version = "0.6.3"
2212+source = "registry+https://github.com/rust-lang/crates.io-index"
2213+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2214+
2215+[[package]]
2216+name = "x25519-dalek"
2217+version = "2.0.1"
2218+source = "registry+https://github.com/rust-lang/crates.io-index"
2219+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
2220+dependencies = [
2221+ "curve25519-dalek",
2222+ "rand_core 0.6.4",
2223+ "serde",
2224+ "zeroize",
2225+]
2226+
2227+[[package]]
2228+name = "yoke"
2229+version = "0.8.3"
2230+source = "registry+https://github.com/rust-lang/crates.io-index"
2231+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2232+dependencies = [
2233+ "stable_deref_trait",
2234+ "yoke-derive",
2235+ "zerofrom",
2236+]
2237+
2238+[[package]]
2239+name = "yoke-derive"
2240+version = "0.8.2"
2241+source = "registry+https://github.com/rust-lang/crates.io-index"
2242+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2243+dependencies = [
2244+ "proc-macro2",
2245+ "quote",
2246+ "syn 2.0.118",
2247+ "synstructure",
2248+]
2249+
2250+[[package]]
2251+name = "zerocopy"
2252+version = "0.8.52"
2253+source = "registry+https://github.com/rust-lang/crates.io-index"
2254+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2255+dependencies = [
2256+ "zerocopy-derive",
2257+]
2258+
2259+[[package]]
2260+name = "zerocopy-derive"
2261+version = "0.8.52"
2262+source = "registry+https://github.com/rust-lang/crates.io-index"
2263+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2264+dependencies = [
2265+ "proc-macro2",
2266+ "quote",
2267+ "syn 2.0.118",
2268+]
2269+
2270+[[package]]
2271+name = "zerofrom"
2272+version = "0.1.8"
2273+source = "registry+https://github.com/rust-lang/crates.io-index"
2274+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2275+dependencies = [
2276+ "zerofrom-derive",
2277+]
2278+
2279+[[package]]
2280+name = "zerofrom-derive"
2281+version = "0.1.7"
2282+source = "registry+https://github.com/rust-lang/crates.io-index"
2283+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2284+dependencies = [
2285+ "proc-macro2",
2286+ "quote",
2287+ "syn 2.0.118",
2288+ "synstructure",
2289+]
2290+
2291+[[package]]
2292+name = "zeroize"
2293+version = "1.9.0"
2294+source = "registry+https://github.com/rust-lang/crates.io-index"
2295+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2296+dependencies = [
2297+ "zeroize_derive",
2298+]
2299+
2300+[[package]]
2301+name = "zeroize_derive"
2302+version = "1.5.0"
2303+source = "registry+https://github.com/rust-lang/crates.io-index"
2304+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
2305+dependencies = [
2306+ "proc-macro2",
2307+ "quote",
2308+ "syn 2.0.118",
2309+]
2310+
2311+[[package]]
2312+name = "zerotrie"
2313+version = "0.2.4"
2314+source = "registry+https://github.com/rust-lang/crates.io-index"
2315+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2316+dependencies = [
2317+ "displaydoc",
2318+ "yoke",
2319+ "zerofrom",
2320+]
2321+
2322+[[package]]
2323+name = "zerovec"
2324+version = "0.11.6"
2325+source = "registry+https://github.com/rust-lang/crates.io-index"
2326+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2327+dependencies = [
2328+ "yoke",
2329+ "zerofrom",
2330+ "zerovec-derive",
2331+]
2332+
2333+[[package]]
2334+name = "zerovec-derive"
2335+version = "0.11.3"
2336+source = "registry+https://github.com/rust-lang/crates.io-index"
2337+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2338+dependencies = [
2339+ "proc-macro2",
2340+ "quote",
2341+ "syn 2.0.118",
2342+]
2343+
2344+[[package]]
2345+name = "zmij"
2346+version = "1.0.21"
2347+source = "registry+https://github.com/rust-lang/crates.io-index"
2348+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2349+
2350+[[package]]
2351+name = "zstd"
2352+version = "0.13.3"
2353+source = "registry+https://github.com/rust-lang/crates.io-index"
2354+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
2355+dependencies = [
2356+ "zstd-safe",
2357+]
2358+
2359+[[package]]
2360+name = "zstd-safe"
2361+version = "7.2.4"
2362+source = "registry+https://github.com/rust-lang/crates.io-index"
2363+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
2364+dependencies = [
2365+ "zstd-sys",
2366+]
2367+
2368+[[package]]
2369+name = "zstd-sys"
2370+version = "2.0.16+zstd.1.5.7"
2371+source = "registry+https://github.com/rust-lang/crates.io-index"
2372+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
2373+dependencies = [
2374+ "cc",
2375+ "pkg-config",
2376+]
Cargo.tomlnew file+39
@@ -0,0 +1,39 @@
1+[workspace]
2+members = ["core"]
3+resolver = "2"
4+
5+[workspace.package]
6+edition = "2021"
7+rust-version = "1.90"
8+license = "MIT"
9+repository = "https://github.com/iko/skrepka"
10+
11+[workspace.dependencies]
12+crux_core = "0.19"
13+crux_http = "0.18"
14+crux_kv = "0.13"
15+crux_time = "0.17"
16+boltffi = "0.26"
17+facet = { version = "=0.44", features = ["chrono"] }
18+serde = { version = "1.0", features = ["derive"] }
19+serde_json = "1.0"
20+
21+# crypto
22+ed25519-dalek = { version = "2", features = ["rand_core"] }
23+x25519-dalek = { version = "2", features = ["static_secrets"] }
24+curve25519-dalek = "4"
25+chacha20poly1305 = "0.10"
26+hkdf = "0.12"
27+sha2 = "0.10"
28+zstd = "0.13"
29+rand_core = { version = "0.6", features = ["getrandom"] }
30+rand_chacha = "0.3"
31+uuid = { version = "1", features = ["v4"] }
32+hex = "0.4"
33+base64 = "0.22"
34+
35+# codegen bin
36+anyhow = "1.0"
37+clap = { version = "4", features = ["derive"] }
38+log = "0.4"
39+pretty_env_logger = "0.5"
Justfilenew file+48
@@ -0,0 +1,48 @@
1+# Skrepka iOS client (Rust core + Crux + SwiftUI shell)
2+#
3+# One-time setup:
4+# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
5+# cargo install boltffi_cli
6+# brew install xcodegen
7+
8+sim_id := env_var_or_default("SIM_ID", "")
9+bundle := "lol.skrepka.SkrepkaApp-iOS"
10+
11+default: test
12+
13+# Run the Rust core test suite (crypto, protocol, @p, state machine).
14+test:
15+ cargo test -p skrepka_core
16+
17+# Generate the Swift type bindings from the core.
18+typegen:
19+ cargo run -q -p skrepka_core --bin codegen --features codegen -- \
20+ --language swift --output-dir apple/generated/App
21+
22+# Build the core for Apple and package the xcframework + Swift package.
23+pack:
24+ cd core && boltffi pack apple
25+
26+# Generate the Xcode project from project.yml.
27+project:
28+ cd apple && xcodegen generate
29+
30+# Full regeneration: types, xcframework, Xcode project.
31+generate: typegen pack project
32+
33+# Build the iOS app for a booted simulator (set SIM_ID, or pass a destination).
34+build sim=sim_id:
35+ cd apple && xcodebuild -project SkrepkaApp.xcodeproj -scheme SkrepkaApp-iOS \
36+ -destination 'platform=iOS Simulator,id={{sim}}' \
37+ -derivedDataPath build -skipPackagePluginValidation build
38+
39+# Install + launch on a booted simulator.
40+run sim=sim_id:
41+ cd apple && xcrun simctl install {{sim}} \
42+ build/Build/Products/Debug-iphonesimulator/SkrepkaApp-iOS.app
43+ xcrun simctl launch {{sim}} {{bundle}}
44+
45+# Run a local relay server for testing (resets the disposable relay db first).
46+server:
47+ rm -f server.db server.db-wal server.db-shm
48+ ./server
PROTOCOL.md+6−6
@@ -69,7 +69,7 @@ There is no global directory. Users assign local nicknames to public keys on the
6969| Key agreement | X25519 |
7070| Key derivation | HKDF-SHA256 |
7171| Symmetric cipher | XChaCha20-Poly1305 |
72−| Compression | LZ4 (pre-encryption) |
72+| Compression | zstd (pre-encryption) |
7373
7474### Per-Message Encryption
7575
@@ -97,9 +97,9 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
9797 len = 32
9898 )
9999 ```
100−4. LZ4-compress the plaintext payload (see §4):
100+4. zstd-compress the plaintext payload (see §4):
101101 ```
102− compressed = lz4_pickle(plaintext_bytes)
102+ compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
103103 ```
1041045. Sign the recipient pubkey concatenated with the compressed plaintext. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
105105 ```
@@ -127,7 +127,7 @@ The wire-visible "envelope" carries only the recipient and the opaque blob. The
1271273. AEAD-decrypt the ciphertext to recover the inner buffer.
1281284. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, remainder is `compressed`.
1291295. Verify the signature over `recipient_ed25519_public || compressed` using `sender_ed25519_public`. Reject the message on failure.
130−6. LZ4-decompress to recover the plaintext payload.
130+6. zstd-decompress to recover the plaintext payload.
131131
132132### Encrypted Blob Format
133133
@@ -144,7 +144,7 @@ After AEAD decryption, the inner buffer is:
144144```
145145sender_ed25519_pubkey (32 bytes)
146146signature (64 bytes)
147−compressed_plaintext (variable, LZ4)
147+compressed_plaintext (variable, zstd)
148148```
149149
150150The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The minimum blob size is `32 + 24 + 32 + 64 + 16 = 168` bytes.
@@ -177,7 +177,7 @@ Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not
177177
178178### Plaintext Payload
179179
180−After decryption (and LZ4 decompression), the plaintext is a UTF-8 JSON object with a `type` field:
180+After decryption (and zstd decompression), the plaintext is a UTF-8 JSON object with a `type` field:
181181
182182```json
183183{
apple/SkrepkaApp/Core.swiftnew file+74
@@ -0,0 +1,74 @@
1+import Foundation
2+import Skrepka
3+import SkrepkaShared
4+
5+/// Drives the Rust core: serializes events in, deserializes effects out, performs
6+/// each effect (HTTP, key-value, timer, render) and resolves it back into the core.
7+@MainActor
8+final class Core: ObservableObject {
9+ @Published var view: ViewModel
10+
11+ private let core = CoreFFI()
12+ private let store = KvStore()
13+
14+ init() {
15+ // swiftlint:disable:next force_try
16+ self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
17+ bootIdentity()
18+ }
19+
20+ // MARK: - Event/effect loop
21+
22+ func update(_ event: Event) {
23+ // swiftlint:disable:next force_try
24+ let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
25+ dispatch(effects)
26+ }
27+
28+ private func resolve(_ id: UInt32, _ output: [UInt8]) {
29+ let effects = [UInt8](core.resolve(id: id, data: Data(output)))
30+ dispatch(effects)
31+ }
32+
33+ private func dispatch(_ effects: [UInt8]) {
34+ // swiftlint:disable:next force_try
35+ let requests = try! Requests.bincodeDeserialize(input: effects).value
36+ for request in requests {
37+ process(request)
38+ }
39+ }
40+
41+ private func process(_ request: Request) {
42+ let id = request.id
43+ switch request.effect {
44+ case .render:
45+ // swiftlint:disable:next force_try
46+ self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
47+
48+ case .http(let req):
49+ Task { [weak self] in
50+ let result = await Http.perform(req)
51+ // swiftlint:disable:next force_try
52+ self?.resolve(id, try! result.bincodeSerialize())
53+ }
54+
55+ case .keyValue(let op):
56+ let result = store.handle(op)
57+ // swiftlint:disable:next force_try
58+ resolve(id, try! result.bincodeSerialize())
59+
60+ case .time(let req):
61+ Time.handle(req) { [weak self] response in
62+ // swiftlint:disable:next force_try
63+ self?.resolve(id, try! response.bincodeSerialize())
64+ }
65+ }
66+ }
67+
68+ // MARK: - Identity
69+
70+ private func bootIdentity() {
71+ let key = Keychain.loadOrCreateIdentity()
72+ update(.identityLoaded([UInt8](key)))
73+ }
74+}
apple/SkrepkaApp/Effects.swiftnew file+158
@@ -0,0 +1,158 @@
1+import CryptoKit
2+import Foundation
3+import Security
4+import Skrepka
5+
6+// MARK: - HTTP
7+
8+enum Http {
9+ static func perform(_ req: HttpRequest) async -> HttpResult {
10+ guard let url = URL(string: req.url) else {
11+ return .err(.url(req.url))
12+ }
13+ var request = URLRequest(url: url)
14+ request.httpMethod = req.method
15+ request.timeoutInterval = 70 // server long-polls for ~25s
16+ for header in req.headers {
17+ request.setValue(header.value, forHTTPHeaderField: header.name)
18+ }
19+ if !req.body.isEmpty {
20+ request.httpBody = Data(req.body)
21+ }
22+ do {
23+ let (data, response) = try await URLSession.shared.data(for: request)
24+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
25+ return .ok(HttpResponse(
26+ status: UInt16(clamping: status),
27+ headers: [],
28+ body: [UInt8](data)
29+ ))
30+ } catch let error as URLError where error.code == .timedOut {
31+ return .err(.timeout)
32+ } catch {
33+ return .err(.io(error.localizedDescription))
34+ }
35+ }
36+}
37+
38+// MARK: - Timer
39+
40+enum Time {
41+ static func handle(_ req: TimeRequest, resolve: @escaping (TimeResponse) -> Void) {
42+ switch req {
43+ case .now:
44+ let now = Date().timeIntervalSince1970
45+ let seconds = UInt64(now)
46+ let nanos = UInt32((now - Double(seconds)) * 1_000_000_000)
47+ resolve(.now(instant: Instant(seconds: seconds, nanos: nanos)))
48+ case .notifyAfter(let id, let duration):
49+ let seconds = Double(duration.nanos) / 1_000_000_000
50+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds) {
51+ resolve(.durationElapsed(id: id))
52+ }
53+ case .notifyAt(let id, let instant):
54+ let target = Date(timeIntervalSince1970: Double(instant.seconds)
55+ + Double(instant.nanos) / 1_000_000_000)
56+ let delay = max(0, target.timeIntervalSinceNow)
57+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
58+ resolve(.instantArrived(id: id))
59+ }
60+ case .clear(let id):
61+ resolve(.cleared(id: id))
62+ }
63+ }
64+}
65+
66+// MARK: - Key-value store (one file per key under Application Support)
67+
68+final class KvStore {
69+ private let dir: URL
70+
71+ init() {
72+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
73+ dir = base.appendingPathComponent("skrepka/kv", isDirectory: true)
74+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
75+ }
76+
77+ private func fileURL(_ key: String) -> URL {
78+ let safe = key.replacingOccurrences(of: ":", with: "_")
79+ .replacingOccurrences(of: "/", with: "_")
80+ return dir.appendingPathComponent(safe)
81+ }
82+
83+ private func read(_ key: String) -> Value {
84+ guard let data = try? Data(contentsOf: fileURL(key)) else { return .none }
85+ return .bytes([UInt8](data))
86+ }
87+
88+ func handle(_ op: KeyValueOperation) -> KeyValueResult {
89+ switch op {
90+ case .get(let key):
91+ return .ok(response: .get(value: read(key)))
92+ case .set(let key, let value):
93+ let previous = read(key)
94+ try? Data(value).write(to: fileURL(key))
95+ return .ok(response: .set(previous: previous))
96+ case .delete(let key):
97+ let previous = read(key)
98+ try? FileManager.default.removeItem(at: fileURL(key))
99+ return .ok(response: .delete(previous: previous))
100+ case .exists(let key):
101+ let present = FileManager.default.fileExists(atPath: fileURL(key).path)
102+ return .ok(response: .exists(isPresent: present))
103+ case .listKeys(_, _):
104+ return .ok(response: .listKeys(keys: [], nextCursor: 0))
105+ }
106+ }
107+}
108+
109+// MARK: - Keychain identity (64-byte libsodium-form Ed25519 secret key)
110+
111+enum Keychain {
112+ private static let service = "lol.skrepka.identity"
113+ private static let account = "identity"
114+
115+ /// Load the stored 64-byte key, or generate and persist a fresh valid one.
116+ static func loadOrCreateIdentity() -> Data {
117+ if let existing = load(), existing.count == 64 {
118+ return existing
119+ }
120+ // Generate a valid Ed25519 keypair; sk64 = seed(32) || pub(32).
121+ let priv = Curve25519.Signing.PrivateKey()
122+ var key = Data()
123+ key.append(priv.rawRepresentation) // 32-byte seed
124+ key.append(priv.publicKey.rawRepresentation) // 32-byte public key
125+ store(key)
126+ return key
127+ }
128+
129+ private static func load() -> Data? {
130+ let query: [String: Any] = [
131+ kSecClass as String: kSecClassGenericPassword,
132+ kSecAttrService as String: service,
133+ kSecAttrAccount as String: account,
134+ kSecReturnData as String: true,
135+ kSecMatchLimit as String: kSecMatchLimitOne,
136+ ]
137+ var item: CFTypeRef?
138+ guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
139+ return item as? Data
140+ }
141+
142+ private static func store(_ data: Data) {
143+ let delete: [String: Any] = [
144+ kSecClass as String: kSecClassGenericPassword,
145+ kSecAttrService as String: service,
146+ kSecAttrAccount as String: account,
147+ ]
148+ SecItemDelete(delete as CFDictionary)
149+ let add: [String: Any] = [
150+ kSecClass as String: kSecClassGenericPassword,
151+ kSecAttrService as String: service,
152+ kSecAttrAccount as String: account,
153+ kSecValueData as String: data,
154+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
155+ ]
156+ SecItemAdd(add as CFDictionary, nil)
157+ }
158+}
apple/SkrepkaApp/Forms.swiftnew file+156
@@ -0,0 +1,156 @@
1+import Skrepka
2+import SwiftUI
3+import UIKit
4+
5+func decodeImage(_ base64: String) -> UIImage? {
6+ guard !base64.isEmpty, let data = Data(base64Encoded: base64) else { return nil }
7+ return UIImage(data: data)
8+}
9+
10+struct AddContactView: View {
11+ @ObservedObject var core: Core
12+ @State private var input = ""
13+ @State private var nickname = ""
14+ @State private var scanning = false
15+
16+ var body: some View {
17+ NavigationStack {
18+ Form {
19+ Section("Public key") {
20+ TextField("@p syllables or hex", text: $input, axis: .vertical)
21+ .autocorrectionDisabled()
22+ .textInputAutocapitalization(.never)
23+ Button { scanning = true } label: {
24+ Label("Scan QR code", systemImage: "qrcode.viewfinder")
25+ }
26+ }
27+ Section("Nickname (optional)") {
28+ TextField("e.g. Alice", text: $nickname)
29+ }
30+ if !core.view.error.isEmpty {
31+ Text(core.view.error).foregroundColor(.red)
32+ }
33+ }
34+ .navigationTitle("Add contact")
35+ .toolbar {
36+ ToolbarItem(placement: .cancellationAction) {
37+ Button("Cancel") { core.update(.showConversations) }
38+ }
39+ ToolbarItem(placement: .confirmationAction) {
40+ Button("Add") {
41+ core.update(.addContact(input: input, nickname: nickname))
42+ }.disabled(input.isEmpty)
43+ }
44+ }
45+ .sheet(isPresented: $scanning) {
46+ QRScannerView { code in
47+ input = code
48+ scanning = false
49+ }
50+ }
51+ }
52+ }
53+}
54+
55+struct SettingsView: View {
56+ @ObservedObject var core: Core
57+ @State private var serverUrl = ""
58+ @State private var copied = false
59+
60+ var body: some View {
61+ NavigationStack {
62+ Form {
63+ Section("Your identity") {
64+ HStack {
65+ Avatar(base64: core.view.profile.photo, name: core.view.profile.displayName)
66+ VStack(alignment: .leading) {
67+ Text(core.view.profile.displayName.isEmpty
68+ ? "No name" : core.view.profile.displayName).font(.headline)
69+ Text(core.view.connStatus.capitalized)
70+ .font(.caption).foregroundColor(.secondary)
71+ }
72+ }
73+ if let qr = qrImage(core.view.myPubkeyHex) {
74+ Image(uiImage: qr).interpolation(.none).resizable().scaledToFit()
75+ .frame(maxWidth: 200).frame(maxWidth: .infinity)
76+ }
77+ Text(core.view.myPubkeyOb).font(.system(.footnote, design: .monospaced))
78+ .textSelection(.enabled)
79+ Button {
80+ UIPasteboard.general.string = core.view.myPubkeyHex
81+ copied = true
82+ } label: {
83+ Label(copied ? "Copied!" : "Copy key", systemImage: "doc.on.doc")
84+ }
85+ Button { core.update(.showEditProfile) } label: {
86+ Label("Edit profile", systemImage: "pencil")
87+ }
88+ }
89+ Section("Server") {
90+ TextField("https://relay.example.com", text: $serverUrl)
91+ .autocorrectionDisabled().textInputAutocapitalization(.never)
92+ Button("Save & reconnect") {
93+ core.update(.setServerUrl(serverUrl))
94+ }.disabled(serverUrl.isEmpty || serverUrl == core.view.serverUrl)
95+ }
96+ }
97+ .navigationTitle("Settings")
98+ .toolbar {
99+ ToolbarItem(placement: .cancellationAction) {
100+ Button("Done") { core.update(.showConversations) }
101+ }
102+ }
103+ .onAppear { serverUrl = core.view.serverUrl }
104+ }
105+ }
106+}
107+
108+struct EditProfileView: View {
109+ @ObservedObject var core: Core
110+ @State private var name = ""
111+ @State private var bio = ""
112+ @State private var photo: String?
113+ @State private var picking = false
114+
115+ var body: some View {
116+ NavigationStack {
117+ Form {
118+ Section {
119+ HStack {
120+ Spacer()
121+ Button { picking = true } label: {
122+ Avatar(base64: photo ?? "", name: name, size: 88)
123+ }
124+ Spacer()
125+ }
126+ Button("Choose photo") { picking = true }
127+ .frame(maxWidth: .infinity)
128+ }
129+ Section("Name") { TextField("Display name", text: $name) }
130+ Section("Bio") { TextField("About you", text: $bio, axis: .vertical) }
131+ }
132+ .navigationTitle("Edit profile")
133+ .toolbar {
134+ ToolbarItem(placement: .cancellationAction) {
135+ Button("Cancel") { core.update(.showConversations) }
136+ }
137+ ToolbarItem(placement: .confirmationAction) {
138+ Button("Save") {
139+ core.update(.saveProfile(displayName: name, bio: bio, photo: photo))
140+ }
141+ }
142+ }
143+ .sheet(isPresented: $picking) {
144+ PhotoPicker { base64 in
145+ photo = base64
146+ picking = false
147+ }
148+ }
149+ .onAppear {
150+ name = core.view.profile.displayName
151+ bio = core.view.profile.bio
152+ photo = core.view.profile.photo.isEmpty ? nil : core.view.profile.photo
153+ }
154+ }
155+ }
156+}
apple/SkrepkaApp/Info-iOS.plistnew file+39
@@ -0,0 +1,39 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleDevelopmentRegion</key>
6+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Skrepka</string>
9+ <key>CFBundleExecutable</key>
10+ <string>$(EXECUTABLE_NAME)</string>
11+ <key>CFBundleIdentifier</key>
12+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13+ <key>CFBundleInfoDictionaryVersion</key>
14+ <string>6.0</string>
15+ <key>CFBundleName</key>
16+ <string>$(PRODUCT_NAME)</string>
17+ <key>CFBundlePackageType</key>
18+ <string>APPL</string>
19+ <key>CFBundleShortVersionString</key>
20+ <string>1.0</string>
21+ <key>CFBundleVersion</key>
22+ <string>1</string>
23+ <key>NSAppTransportSecurity</key>
24+ <dict>
25+ <key>NSAllowsArbitraryLoads</key>
26+ <true/>
27+ </dict>
28+ <key>NSCameraUsageDescription</key>
29+ <string>Scan a contact's QR code to add them.</string>
30+ <key>NSPhotoLibraryUsageDescription</key>
31+ <string>Choose a photo for your profile.</string>
32+ <key>UILaunchScreen</key>
33+ <dict/>
34+ <key>UISupportedInterfaceOrientations</key>
35+ <array>
36+ <string>UIInterfaceOrientationPortrait</string>
37+ </array>
38+</dict>
39+</plist>
apple/SkrepkaApp/PhotoPicker.swiftnew file+49
@@ -0,0 +1,49 @@
1+import PhotosUI
2+import SwiftUI
3+import UIKit
4+
5+/// Presents the system photo picker; returns a downscaled JPEG as base64.
6+struct PhotoPicker: UIViewControllerRepresentable {
7+ let onPick: (String) -> Void
8+
9+ func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) }
10+
11+ func makeUIViewController(context: Context) -> PHPickerViewController {
12+ var config = PHPickerConfiguration()
13+ config.filter = .images
14+ config.selectionLimit = 1
15+ let picker = PHPickerViewController(configuration: config)
16+ picker.delegate = context.coordinator
17+ return picker
18+ }
19+
20+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
21+
22+ final class Coordinator: NSObject, PHPickerViewControllerDelegate {
23+ let onPick: (String) -> Void
24+ init(onPick: @escaping (String) -> Void) { self.onPick = onPick }
25+
26+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
27+ guard let provider = results.first?.itemProvider,
28+ provider.canLoadObject(ofClass: UIImage.self) else {
29+ DispatchQueue.main.async { picker.dismiss(animated: true) }
30+ return
31+ }
32+ provider.loadObject(ofClass: UIImage.self) { object, _ in
33+ guard let image = object as? UIImage,
34+ let base64 = Self.downscaledBase64(image) else { return }
35+ DispatchQueue.main.async { self.onPick(base64) }
36+ }
37+ }
38+
39+ /// Resize to fit 256px and JPEG-encode, to keep the profile payload small.
40+ static func downscaledBase64(_ image: UIImage) -> String? {
41+ let maxDim: CGFloat = 256
42+ let scale = min(1, maxDim / max(image.size.width, image.size.height))
43+ let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
44+ let renderer = UIGraphicsImageRenderer(size: size)
45+ let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
46+ return resized.jpegData(compressionQuality: 0.7)?.base64EncodedString()
47+ }
48+ }
49+}
apple/SkrepkaApp/QRView.swiftnew file+85
@@ -0,0 +1,85 @@
1+import AVFoundation
2+import CoreImage.CIFilterBuiltins
3+import SwiftUI
4+import UIKit
5+
6+/// Generate a QR code image from a string.
7+func qrImage(_ string: String) -> UIImage? {
8+ let context = CIContext()
9+ let filter = CIFilter.qrCodeGenerator()
10+ filter.message = Data(string.utf8)
11+ guard let output = filter.outputImage else { return nil }
12+ let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
13+ guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
14+ return UIImage(cgImage: cg)
15+}
16+
17+/// A camera-backed QR scanner. Calls `onScan` with the decoded payload once.
18+struct QRScannerView: UIViewControllerRepresentable {
19+ let onScan: (String) -> Void
20+
21+ func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan) }
22+
23+ func makeUIViewController(context: Context) -> ScannerVC {
24+ let vc = ScannerVC()
25+ vc.coordinator = context.coordinator
26+ return vc
27+ }
28+
29+ func updateUIViewController(_ uiViewController: ScannerVC, context: Context) {}
30+
31+ final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
32+ let onScan: (String) -> Void
33+ private var done = false
34+ init(onScan: @escaping (String) -> Void) { self.onScan = onScan }
35+
36+ func metadataOutput(
37+ _ output: AVCaptureMetadataOutput,
38+ didOutput metadataObjects: [AVMetadataObject],
39+ from connection: AVCaptureConnection
40+ ) {
41+ guard !done,
42+ let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
43+ let value = obj.stringValue else { return }
44+ done = true
45+ DispatchQueue.main.async { self.onScan(value) }
46+ }
47+ }
48+}
49+
50+final class ScannerVC: UIViewController {
51+ weak var coordinator: QRScannerView.Coordinator?
52+ private let session = AVCaptureSession()
53+
54+ override func viewDidLoad() {
55+ super.viewDidLoad()
56+ view.backgroundColor = .black
57+ guard let device = AVCaptureDevice.default(for: .video),
58+ let input = try? AVCaptureDeviceInput(device: device),
59+ session.canAddInput(input) else { return }
60+ session.addInput(input)
61+
62+ let output = AVCaptureMetadataOutput()
63+ guard session.canAddOutput(output) else { return }
64+ session.addOutput(output)
65+ output.setMetadataObjectsDelegate(coordinator, queue: .main)
66+ output.metadataObjectTypes = [.qr]
67+
68+ let preview = AVCaptureVideoPreviewLayer(session: session)
69+ preview.frame = view.layer.bounds
70+ preview.videoGravity = .resizeAspectFill
71+ view.layer.addSublayer(preview)
72+ }
73+
74+ override func viewWillAppear(_ animated: Bool) {
75+ super.viewWillAppear(animated)
76+ if !session.isRunning {
77+ DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
78+ }
79+ }
80+
81+ override func viewWillDisappear(_ animated: Bool) {
82+ super.viewWillDisappear(animated)
83+ if session.isRunning { session.stopRunning() }
84+ }
85+}
apple/SkrepkaApp/SkrepkaApp.swiftnew file+12
@@ -0,0 +1,12 @@
1+import SwiftUI
2+
3+@main
4+struct SkrepkaApp: App {
5+ @StateObject private var core = Core()
6+
7+ var body: some Scene {
8+ WindowGroup {
9+ RootView(core: core)
10+ }
11+ }
12+}
apple/SkrepkaApp/Views.swiftnew file+208
@@ -0,0 +1,208 @@
1+import Skrepka
2+import SwiftUI
3+
4+let accent = Color(red: 98 / 255, green: 54 / 255, blue: 1.0)
5+
6+func formatTime(_ ms: Int64) -> String {
7+ let date = Date(timeIntervalSince1970: Double(ms) / 1000)
8+ let fmt = DateFormatter()
9+ fmt.dateFormat = "HH:mm"
10+ return fmt.string(from: date)
11+}
12+
13+struct RootView: View {
14+ @ObservedObject var core: Core
15+
16+ var body: some View {
17+ Group {
18+ if !core.view.hasIdentity {
19+ ProgressView("Generating identity…")
20+ } else {
21+ switch core.view.page {
22+ case "chat": ChatView(core: core)
23+ case "add_contact": AddContactView(core: core)
24+ case "settings": SettingsView(core: core)
25+ case "edit_profile": EditProfileView(core: core)
26+ default: ConversationsView(core: core)
27+ }
28+ }
29+ }
30+ }
31+}
32+
33+struct ConnDot: View {
34+ let status: String
35+ var color: Color {
36+ switch status {
37+ case "online": return .green
38+ case "connecting": return .orange
39+ default: return .gray
40+ }
41+ }
42+ var body: some View {
43+ HStack(spacing: 5) {
44+ Circle().fill(color).frame(width: 8, height: 8)
45+ Text(status.capitalized).font(.caption).foregroundColor(.secondary)
46+ }
47+ .fixedSize()
48+ }
49+}
50+
51+struct Avatar: View {
52+ let base64: String
53+ let name: String
54+ var size: CGFloat = 44
55+ var body: some View {
56+ if let img = decodeImage(base64) {
57+ Image(uiImage: img).resizable().scaledToFill()
58+ .frame(width: size, height: size).clipShape(Circle())
59+ } else {
60+ ZStack {
61+ Circle().fill(accent.opacity(0.15))
62+ Text(initials(name)).font(.system(size: size * 0.4, weight: .semibold))
63+ .foregroundColor(accent)
64+ }.frame(width: size, height: size)
65+ }
66+ }
67+}
68+
69+func initials(_ name: String) -> String {
70+ let parts = name.split(separator: " ")
71+ let chars = parts.prefix(2).compactMap { $0.first }
72+ return chars.isEmpty ? "?" : String(chars).uppercased()
73+}
74+
75+struct ConversationsView: View {
76+ @ObservedObject var core: Core
77+
78+ var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
79+
80+ var body: some View {
81+ NavigationStack {
82+ Group {
83+ if visible.isEmpty {
84+ ContentUnavailableView(
85+ "No conversations",
86+ systemImage: "bubble.left.and.bubble.right",
87+ description: Text("Tap + to add a contact by their key or QR code.")
88+ )
89+ } else {
90+ List(visible, id: \.pubkey) { contact in
91+ Button { core.update(.openChat(contact.pubkey)) } label: {
92+ HStack(spacing: 12) {
93+ Avatar(base64: contact.photo, name: contact.name)
94+ VStack(alignment: .leading, spacing: 2) {
95+ Text(contact.name).font(.headline).foregroundColor(.primary)
96+ Text(contact.lastMessage.isEmpty ? contact.ob : contact.lastMessage)
97+ .font(.subheadline).foregroundColor(.secondary).lineLimit(1)
98+ }
99+ Spacer()
100+ if contact.lastTs > 0 {
101+ Text(formatTime(contact.lastTs))
102+ .font(.caption).foregroundColor(.secondary)
103+ }
104+ }
105+ }
106+ }
107+ }
108+ }
109+ .navigationTitle("Skrepka")
110+ .toolbar {
111+ ToolbarItem(placement: .topBarLeading) { ConnDot(status: core.view.connStatus) }
112+ ToolbarItem(placement: .topBarTrailing) {
113+ Button { core.update(.showAddContact) } label: { Image(systemName: "plus") }
114+ }
115+ ToolbarItem(placement: .topBarTrailing) {
116+ Button { core.update(.showSettings) } label: { Image(systemName: "gearshape") }
117+ }
118+ }
119+ }
120+ }
121+}
122+
123+struct ChatView: View {
124+ @ObservedObject var core: Core
125+ @State private var draft = ""
126+
127+ var body: some View {
128+ VStack(spacing: 0) {
129+ messages
130+ composer
131+ }
132+ .navigationBarBackButtonHidden(true)
133+ .safeAreaInset(edge: .top) { header }
134+ }
135+
136+ private var header: some View {
137+ HStack(spacing: 12) {
138+ Button { core.update(.back) } label: { Image(systemName: "chevron.left") }
139+ VStack(alignment: .leading, spacing: 1) {
140+ Text(core.view.activePeerName).font(.headline)
141+ Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
142+ }
143+ Spacer()
144+ }
145+ .padding(.horizontal).padding(.vertical, 8)
146+ .background(.bar)
147+ }
148+
149+ private var messages: some View {
150+ ScrollViewReader { proxy in
151+ ScrollView {
152+ LazyVStack(spacing: 8) {
153+ ForEach(core.view.messages, id: \.id) { msg in
154+ MessageBubble(msg: msg).id(msg.id)
155+ }
156+ }.padding()
157+ }
158+ .onChange(of: core.view.messages.count) {
159+ if let last = core.view.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
160+ }
161+ }
162+ }
163+
164+ private var composer: some View {
165+ HStack(spacing: 8) {
166+ TextField("Message", text: $draft, axis: .vertical)
167+ .textFieldStyle(.roundedBorder)
168+ .lineLimit(1...4)
169+ Button {
170+ let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
171+ guard !text.isEmpty else { return }
172+ core.update(.composeChanged(text))
173+ core.update(.sendText)
174+ draft = ""
175+ } label: {
176+ Image(systemName: "arrow.up.circle.fill").font(.title)
177+ }
178+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
179+ }
180+ .padding(.horizontal).padding(.vertical, 8)
181+ .background(.bar)
182+ }
183+}
184+
185+struct MessageBubble: View {
186+ let msg: MessageVM
187+ var body: some View {
188+ HStack {
189+ if msg.outgoing { Spacer(minLength: 40) }
190+ VStack(alignment: .trailing, spacing: 2) {
191+ Text(msg.body)
192+ .foregroundColor(msg.outgoing ? .white : .primary)
193+ HStack(spacing: 4) {
194+ Text(formatTime(msg.ts)).font(.caption2)
195+ .foregroundColor(msg.outgoing ? .white.opacity(0.7) : .secondary)
196+ if msg.outgoing {
197+ Image(systemName: msg.delivered ? "checkmark.circle.fill" : "checkmark")
198+ .font(.caption2).foregroundColor(.white.opacity(0.8))
199+ }
200+ }
201+ }
202+ .padding(.horizontal, 12).padding(.vertical, 8)
203+ .background(msg.outgoing ? accent : Color(.systemGray5))
204+ .clipShape(RoundedRectangle(cornerRadius: 16))
205+ if !msg.outgoing { Spacer(minLength: 40) }
206+ }
207+ }
208+}
apple/project.ymlnew file+41
@@ -0,0 +1,41 @@
1+name: SkrepkaApp
2+packages:
3+ Shared:
4+ path: ./generated/Shared
5+ App:
6+ path: ./generated/App/Skrepka
7+options:
8+ bundleIdPrefix: lol.skrepka
9+ deploymentTarget:
10+ iOS: "17.0"
11+attributes:
12+ BuildIndependentTargetsInParallel: true
13+
14+targets:
15+ SkrepkaApp-iOS:
16+ type: application
17+ platform: iOS
18+ deploymentTarget: "17.0"
19+ sources:
20+ - path: SkrepkaApp
21+ excludes:
22+ - "Info-*.plist"
23+ info:
24+ path: SkrepkaApp/Info-iOS.plist
25+ properties:
26+ CFBundleDisplayName: Skrepka
27+ UILaunchScreen: {}
28+ UISupportedInterfaceOrientations:
29+ - UIInterfaceOrientationPortrait
30+ NSCameraUsageDescription: Scan a contact's QR code to add them.
31+ NSPhotoLibraryUsageDescription: Choose a photo for your profile.
32+ NSAppTransportSecurity:
33+ NSAllowsArbitraryLoads: true
34+ dependencies:
35+ - package: Shared
36+ product: SkrepkaShared
37+ - package: App
38+ product: Skrepka
39+ scheme:
40+ management:
41+ shared: true
core/Cargo.tomlnew file+58
@@ -0,0 +1,58 @@
1+[package]
2+name = "skrepka_core"
3+version = "0.1.0"
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+repository.workspace = true
8+
9+[lib]
10+name = "skrepka_core"
11+crate-type = ["cdylib", "lib", "staticlib"]
12+
13+[[bin]]
14+name = "codegen"
15+required-features = ["codegen"]
16+
17+[features]
18+facet_typegen = ["crux_core/facet_typegen"]
19+codegen = [
20+ "dep:anyhow",
21+ "dep:clap",
22+ "dep:log",
23+ "dep:pretty_env_logger",
24+ "facet_typegen",
25+]
26+
27+[dependencies]
28+crux_core.workspace = true
29+crux_http.workspace = true
30+crux_kv.workspace = true
31+crux_time.workspace = true
32+boltffi.workspace = true
33+facet.workspace = true
34+serde.workspace = true
35+serde_json.workspace = true
36+
37+# crypto
38+ed25519-dalek.workspace = true
39+x25519-dalek.workspace = true
40+curve25519-dalek.workspace = true
41+chacha20poly1305.workspace = true
42+hkdf.workspace = true
43+sha2.workspace = true
44+zstd.workspace = true
45+rand_core.workspace = true
46+uuid.workspace = true
47+hex.workspace = true
48+base64.workspace = true
49+
50+# optional, only for the codegen bin
51+anyhow = { workspace = true, optional = true }
52+clap = { workspace = true, optional = true }
53+log = { workspace = true, optional = true }
54+pretty_env_logger = { workspace = true, optional = true }
55+
56+[dev-dependencies]
57+rand_chacha.workspace = true
58+crux_core = { workspace = true, features = ["testing"] }
core/boltffi.tomlnew file+18
@@ -0,0 +1,18 @@
1+[package]
2+name = "skrepka_core"
3+crate = "skrepka_core"
4+version = "0.1.0"
5+repository = "https://github.com/iko/skrepka"
6+license = "MIT"
7+
8+[targets.apple]
9+output = "../apple/generated/Shared"
10+deployment_target = "17.0"
11+include_macos = false
12+
13+[targets.apple.swift]
14+module_name = "SkrepkaShared"
15+
16+[targets.apple.spm]
17+package_name = "SkrepkaShared"
18+layout = "ffi-only"
core/src/app.rsnew file+1070
@@ -0,0 +1,1070 @@
1+//! Crux App — the MVU state machine for the skrepka client.
2+//!
3+//! The core owns all business logic: identity, auth, the long-poll loop, the
4+//! outbox, contacts, profiles, and message ingest. The shell provides HTTP,
5+//! key-value storage, a timer, and (natively, fed back as events) the Keychain,
6+//! QR scanning, photo picking, and clipboard.
7+
8+use std::collections::{BTreeMap, VecDeque};
9+use std::time::{Duration, SystemTime, UNIX_EPOCH};
10+
11+use crux_core::{
12+ macros::effect,
13+ render::{render, RenderOperation},
14+ App, Command,
15+};
16+use crux_http::protocol::HttpRequest;
17+use crux_kv::KeyValueOperation;
18+use crux_time::TimeRequest;
19+use facet::Facet;
20+use serde::{Deserialize, Serialize};
21+
22+use crate::crypto::Identity;
23+use crate::model::{
24+ hex_to_ob, trunc_ob, Contact, ContactVM, MessageVM, OutboxItem, OwnProfile, ProfileVM,
25+ Settings, StoredMessage, ViewModel,
26+};
27+use crate::phonemic;
28+use crate::protocol::{self, Envelope, Payload};
29+
30+type Http = crux_http::command::Http<Effect, Event>;
31+type KeyValue = crux_kv::KeyValue<Effect, Event>;
32+type Time = crux_time::Time<Effect, Event>;
33+type HttpResult = crux_http::Result<crux_http::Response<Vec<u8>>>;
34+type KvData = Result<Option<Vec<u8>>, crux_kv::error::KeyValueError>;
35+
36+// kv keys
37+const K_SETTINGS: &str = "settings";
38+const K_PROFILE: &str = "profile";
39+const K_CONTACTS: &str = "contacts";
40+const K_CURSOR: &str = "cursor";
41+const K_OUTBOX: &str = "outbox";
42+fn k_messages(peer: &str) -> String {
43+ format!("messages:{peer}")
44+}
45+
46+// ---------------------------------------------------------------------------
47+// Effect
48+// ---------------------------------------------------------------------------
49+
50+#[effect(facet_typegen)]
51+#[derive(Debug)]
52+pub enum Effect {
53+ Render(RenderOperation),
54+ Http(HttpRequest),
55+ KeyValue(KeyValueOperation),
56+ Time(TimeRequest),
57+}
58+
59+// ---------------------------------------------------------------------------
60+// Events
61+// ---------------------------------------------------------------------------
62+
63+#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
64+#[repr(C)]
65+pub enum Event {
66+ // ---- lifecycle / persistence (from the shell) ----
67+ /// 64-byte Ed25519 secret key loaded (or freshly generated) by the shell.
68+ IdentityLoaded(Vec<u8>),
69+
70+ // ---- navigation (from the shell) ----
71+ ShowConversations,
72+ ShowSettings,
73+ ShowAddContact,
74+ ShowEditProfile,
75+ OpenChat(String),
76+ Back,
77+
78+ // ---- user actions (from the shell) ----
79+ ComposeChanged(String),
80+ SendText,
81+ AddContact { input: String, nickname: String },
82+ SetBlocked { peer: String, blocked: bool },
83+ SaveProfile {
84+ display_name: String,
85+ bio: String,
86+ photo: Option<String>,
87+ },
88+ SetServerUrl(String),
89+ Connect,
90+
91+ // ---- internal: kv load results ----
92+ #[serde(skip)]
93+ #[facet(skip)]
94+ LoadedSettings(#[facet(opaque)] KvData),
95+ #[serde(skip)]
96+ #[facet(skip)]
97+ LoadedProfile(#[facet(opaque)] KvData),
98+ #[serde(skip)]
99+ #[facet(skip)]
100+ LoadedContacts(#[facet(opaque)] KvData),
101+ #[serde(skip)]
102+ #[facet(skip)]
103+ LoadedCursor(#[facet(opaque)] KvData),
104+ #[serde(skip)]
105+ #[facet(skip)]
106+ LoadedOutbox(#[facet(opaque)] KvData),
107+ #[serde(skip)]
108+ #[facet(skip)]
109+ LoadedMessages(String, #[facet(opaque)] KvData),
110+ #[serde(skip)]
111+ #[facet(skip)]
112+ Saved(#[facet(opaque)] KvData),
113+
114+ // ---- internal: auth / poll / send ----
115+ Authenticate,
116+ Poll,
117+ StartFlush,
118+ #[serde(skip)]
119+ #[facet(skip)]
120+ ChallengeResult(#[facet(opaque)] HttpResult),
121+ #[serde(skip)]
122+ #[facet(skip)]
123+ VerifyResult(#[facet(opaque)] HttpResult),
124+ #[serde(skip)]
125+ #[facet(skip)]
126+ PollResult(#[facet(opaque)] HttpResult),
127+ #[serde(skip)]
128+ #[facet(skip)]
129+ SendResult(#[facet(opaque)] HttpResult),
130+}
131+
132+// ---------------------------------------------------------------------------
133+// Model
134+// ---------------------------------------------------------------------------
135+
136+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
137+pub enum ConnStatus {
138+ Offline,
139+ Connecting,
140+ Online,
141+}
142+
143+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
144+pub enum Page {
145+ Setup,
146+ Conversations,
147+ Chat,
148+ AddContact,
149+ Settings,
150+ EditProfile,
151+}
152+
153+pub struct Model {
154+ secret_key: Option<Vec<u8>>,
155+ my_pubkey: String,
156+ settings: Settings,
157+ profile: OwnProfile,
158+ contacts: BTreeMap<String, Contact>,
159+ messages: BTreeMap<String, Vec<StoredMessage>>,
160+ outbox: VecDeque<OutboxItem>,
161+ cursor: i64,
162+ token: Option<String>,
163+ conn: ConnStatus,
164+ poll_retries: u32,
165+ flushing: bool,
166+ page: Page,
167+ active_peer: Option<String>,
168+ compose: String,
169+ error: Option<String>,
170+}
171+
172+impl Default for Model {
173+ fn default() -> Self {
174+ Model {
175+ secret_key: None,
176+ my_pubkey: String::new(),
177+ settings: Settings::default(),
178+ profile: OwnProfile::default(),
179+ contacts: BTreeMap::new(),
180+ messages: BTreeMap::new(),
181+ outbox: VecDeque::new(),
182+ cursor: 0,
183+ token: None,
184+ conn: ConnStatus::Offline,
185+ poll_retries: 0,
186+ flushing: false,
187+ page: Page::Setup,
188+ active_peer: None,
189+ compose: String::new(),
190+ error: None,
191+ }
192+ }
193+}
194+
195+impl Model {
196+ fn identity(&self) -> Option<Identity> {
197+ self.secret_key
198+ .as_ref()
199+ .and_then(|sk| Identity::from_secret_bytes(sk).ok())
200+ }
201+}
202+
203+// ---------------------------------------------------------------------------
204+// Helpers (pure)
205+// ---------------------------------------------------------------------------
206+
207+fn now_ms() -> i64 {
208+ SystemTime::now()
209+ .duration_since(UNIX_EPOCH)
210+ .map(|d| d.as_millis() as i64)
211+ .unwrap_or(0)
212+}
213+
214+/// Bare lowercased hostname of a server URL (no scheme/port/trailing dot).
215+fn server_host(url: &str) -> String {
216+ let after_scheme = url.split("://").nth(1).unwrap_or(url);
217+ let host_port = after_scheme.split('/').next().unwrap_or("");
218+ let host = host_port.split('@').next_back().unwrap_or(host_port);
219+ let host = host.split(':').next().unwrap_or(host);
220+ host.trim_end_matches('.').to_lowercase()
221+}
222+
223+fn json_bytes<T: Serialize>(value: &T) -> Vec<u8> {
224+ serde_json::to_vec(value).unwrap_or_default()
225+}
226+
227+// ---------------------------------------------------------------------------
228+// Wire request/response shapes
229+// ---------------------------------------------------------------------------
230+
231+#[derive(Serialize)]
232+struct ChallengeReq<'a> {
233+ pubkey: &'a str,
234+}
235+#[derive(Deserialize)]
236+struct ChallengeResp {
237+ challenge: String,
238+}
239+#[derive(Serialize)]
240+struct VerifyReq<'a> {
241+ pubkey: &'a str,
242+ challenge: &'a str,
243+ signature: &'a str,
244+ #[serde(rename = "revokeOthers")]
245+ revoke_others: bool,
246+}
247+#[derive(Deserialize)]
248+struct VerifyResp {
249+ token: String,
250+}
251+#[derive(Serialize)]
252+struct PollReq {
253+ cursor: i64,
254+}
255+#[derive(Deserialize, Default)]
256+struct PollResp {
257+ #[serde(default)]
258+ events: Vec<PollEvent>,
259+ #[serde(default)]
260+ cursor: i64,
261+}
262+#[derive(Deserialize)]
263+struct PollEvent {
264+ #[serde(rename = "encryptedBlob")]
265+ encrypted_blob: String,
266+}
267+#[derive(Serialize)]
268+struct SendBatch {
269+ messages: Vec<Envelope>,
270+}
271+
272+// ---------------------------------------------------------------------------
273+// App
274+// ---------------------------------------------------------------------------
275+
276+#[derive(Default)]
277+pub struct Skrepka;
278+
279+impl App for Skrepka {
280+ type Event = Event;
281+ type Model = Model;
282+ type ViewModel = ViewModel;
283+ type Effect = Effect;
284+
285+ #[allow(clippy::too_many_lines)]
286+ fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
287+ match event {
288+ // ---------------- lifecycle ----------------
289+ Event::IdentityLoaded(sk) => {
290+ let Ok(id) = Identity::from_secret_bytes(&sk) else {
291+ model.error = Some("invalid identity key".into());
292+ return render();
293+ };
294+ model.secret_key = Some(sk);
295+ model.my_pubkey = id.public_key_hex();
296+ model.page = Page::Conversations;
297+ // Fan out the startup loads.
298+ Command::all([
299+ KeyValue::get(K_SETTINGS).then_send(Event::LoadedSettings),
300+ KeyValue::get(K_PROFILE).then_send(Event::LoadedProfile),
301+ KeyValue::get(K_CONTACTS).then_send(Event::LoadedContacts),
302+ KeyValue::get(K_CURSOR).then_send(Event::LoadedCursor),
303+ KeyValue::get(K_OUTBOX).then_send(Event::LoadedOutbox),
304+ ])
305+ .and(render())
306+ }
307+
308+ Event::LoadedSettings(res) => {
309+ if let Some(s) = parse_kv::<Settings>(res) {
310+ model.settings = s;
311+ }
312+ // Server URL known — connect and resume any queued sends.
313+ Command::event(Event::Connect)
314+ }
315+ Event::LoadedProfile(res) => {
316+ if let Some(p) = parse_kv::<OwnProfile>(res) {
317+ model.profile = p;
318+ }
319+ render()
320+ }
321+ Event::LoadedContacts(res) => {
322+ if let Some(list) = parse_kv::<Vec<Contact>>(res) {
323+ model.contacts = list.into_iter().map(|c| (c.pubkey.clone(), c)).collect();
324+ // Lazily load each conversation's messages.
325+ let loads: Vec<_> = model
326+ .contacts
327+ .keys()
328+ .map(|peer| {
329+ let peer = peer.clone();
330+ KeyValue::get(k_messages(&peer))
331+ .then_send(move |r| Event::LoadedMessages(peer.clone(), r))
332+ })
333+ .collect();
334+ return Command::all(loads).and(render());
335+ }
336+ render()
337+ }
338+ Event::LoadedCursor(res) => {
339+ if let Some(c) = parse_kv::<i64>(res) {
340+ model.cursor = c;
341+ }
342+ render()
343+ }
344+ Event::LoadedOutbox(res) => {
345+ if let Some(list) = parse_kv::<Vec<OutboxItem>>(res) {
346+ model.outbox = list.into();
347+ }
348+ render()
349+ }
350+ Event::LoadedMessages(peer, res) => {
351+ if let Some(list) = parse_kv::<Vec<StoredMessage>>(res) {
352+ model.messages.insert(peer, list);
353+ }
354+ render()
355+ }
356+ Event::Saved(_) => Command::done(),
357+
358+ // ---------------- navigation ----------------
359+ Event::ShowConversations => {
360+ model.page = Page::Conversations;
361+ model.active_peer = None;
362+ render()
363+ }
364+ Event::ShowSettings => {
365+ model.page = Page::Settings;
366+ render()
367+ }
368+ Event::ShowAddContact => {
369+ model.page = Page::AddContact;
370+ model.error = None;
371+ render()
372+ }
373+ Event::ShowEditProfile => {
374+ model.page = Page::EditProfile;
375+ render()
376+ }
377+ Event::OpenChat(peer) => {
378+ model.active_peer = Some(peer);
379+ model.page = Page::Chat;
380+ render()
381+ }
382+ Event::Back => {
383+ model.page = Page::Conversations;
384+ model.active_peer = None;
385+ render()
386+ }
387+
388+ // ---------------- user actions ----------------
389+ Event::ComposeChanged(s) => {
390+ model.compose = s;
391+ render()
392+ }
393+ Event::SetServerUrl(url) => {
394+ model.settings.server_url = url;
395+ model.token = None;
396+ model.conn = ConnStatus::Offline;
397+ KeyValue::set(K_SETTINGS, json_bytes(&model.settings))
398+ .then_send(Event::Saved)
399+ .and(Command::event(Event::Connect))
400+ .and(render())
401+ }
402+ Event::Connect => {
403+ if model.secret_key.is_some() && model.conn != ConnStatus::Online {
404+ Command::event(Event::Authenticate)
405+ } else {
406+ render()
407+ }
408+ }
409+ Event::AddContact { input, nickname } => {
410+ match phonemic::try_parse_pubkey(&input) {
411+ Some(hex) if hex != model.my_pubkey => {
412+ model
413+ .contacts
414+ .entry(hex.clone())
415+ .or_insert_with(|| Contact::new(hex.clone(), nickname.clone(), now_ms()));
416+ if !nickname.is_empty() {
417+ if let Some(c) = model.contacts.get_mut(&hex) {
418+ c.nickname = nickname;
419+ }
420+ }
421+ model.error = None;
422+ model.page = Page::Conversations;
423+ self.persist_contacts(model).and(render())
424+ }
425+ Some(_) => {
426+ model.error = Some("that's your own key".into());
427+ render()
428+ }
429+ None => {
430+ model.error = Some("invalid public key".into());
431+ render()
432+ }
433+ }
434+ }
435+ Event::SetBlocked { peer, blocked } => {
436+ if let Some(c) = model.contacts.get_mut(&peer) {
437+ c.blocked = blocked;
438+ }
439+ self.persist_contacts(model).and(render())
440+ }
441+ Event::SaveProfile {
442+ display_name,
443+ bio,
444+ photo,
445+ } => {
446+ model.profile = OwnProfile {
447+ display_name,
448+ bio,
449+ photo,
450+ };
451+ model.page = Page::Conversations;
452+ let ts = now_ms();
453+ let payload = Payload::Profile {
454+ display_name: model.profile.display_name.clone(),
455+ bio: model.profile.bio.clone(),
456+ photo: model.profile.photo.clone(),
457+ };
458+ // Broadcast the new profile to every contact.
459+ for peer in model.contacts.keys().cloned().collect::<Vec<_>>() {
460+ model.outbox.push_back(OutboxItem {
461+ recipient: peer,
462+ envelope_json: protocol::serialize_payload(&payload, ts),
463+ });
464+ }
465+ Command::all([
466+ KeyValue::set(K_PROFILE, json_bytes(&model.profile)).then_send(Event::Saved),
467+ self.persist_outbox(model),
468+ ])
469+ .and(Command::event(Event::StartFlush))
470+ .and(render())
471+ }
472+ Event::SendText => self.send_text(model),
473+
474+ // ---------------- auth ----------------
475+ Event::Authenticate => {
476+ let Some(id) = model.identity() else {
477+ return render();
478+ };
479+ model.conn = ConnStatus::Connecting;
480+ model.error = None;
481+ let url = format!("{}/auth/challenge", model.settings.server_url);
482+ let body = ChallengeReq {
483+ pubkey: &id.public_key_hex(),
484+ };
485+ match Http::post(url).body_json(&body) {
486+ Ok(req) => req.build().then_send(Event::ChallengeResult).and(render()),
487+ Err(_) => render(),
488+ }
489+ }
490+ Event::ChallengeResult(Ok(mut resp)) => {
491+ let bytes = resp.take_body().unwrap_or_default();
492+ let Some(id) = model.identity() else {
493+ return render();
494+ };
495+ match serde_json::from_slice::<ChallengeResp>(&bytes) {
496+ Ok(c) => {
497+ let host = server_host(&model.settings.server_url);
498+ let signature = id.sign_challenge(&host, &c.challenge);
499+ let url = format!("{}/auth/verify", model.settings.server_url);
500+ let body = VerifyReq {
501+ pubkey: &id.public_key_hex(),
502+ challenge: &c.challenge,
503+ signature: &signature,
504+ revoke_others: false,
505+ };
506+ match Http::post(url).body_json(&body) {
507+ Ok(req) => req.build().then_send(Event::VerifyResult),
508+ Err(_) => render(),
509+ }
510+ }
511+ Err(_) => {
512+ model.conn = ConnStatus::Offline;
513+ self.schedule_reconnect(model)
514+ }
515+ }
516+ }
517+ Event::ChallengeResult(Err(_)) => {
518+ model.conn = ConnStatus::Offline;
519+ self.schedule_reconnect(model)
520+ }
521+ Event::VerifyResult(Ok(mut resp)) => {
522+ let bytes = resp.take_body().unwrap_or_default();
523+ match serde_json::from_slice::<VerifyResp>(&bytes) {
524+ Ok(v) if !v.token.is_empty() => {
525+ model.token = Some(v.token);
526+ model.conn = ConnStatus::Online;
527+ model.poll_retries = 0;
528+ model.error = None;
529+ Command::event(Event::Poll)
530+ .and(Command::event(Event::StartFlush))
531+ .and(render())
532+ }
533+ _ => {
534+ model.conn = ConnStatus::Offline;
535+ self.schedule_reconnect(model)
536+ }
537+ }
538+ }
539+ Event::VerifyResult(Err(_)) => {
540+ model.conn = ConnStatus::Offline;
541+ self.schedule_reconnect(model)
542+ }
543+
544+ // ---------------- poll ----------------
545+ Event::Poll => {
546+ let Some(token) = model.token.clone() else {
547+ return Command::event(Event::Authenticate);
548+ };
549+ let url = format!("{}/poll", model.settings.server_url);
550+ let body = PollReq {
551+ cursor: model.cursor,
552+ };
553+ match Http::post(url)
554+ .header("authorization", format!("Bearer {token}"))
555+ .body_json(&body)
556+ {
557+ Ok(req) => req.build().then_send(Event::PollResult),
558+ Err(_) => render(),
559+ }
560+ }
561+ Event::PollResult(Ok(mut resp)) => {
562+ let status = u16::from(resp.status());
563+ if status == 401 {
564+ model.token = None;
565+ return Command::event(Event::Authenticate);
566+ }
567+ if !(200..300).contains(&status) {
568+ return self.backoff_poll(model);
569+ }
570+ model.poll_retries = 0;
571+ let bytes = resp.take_body().unwrap_or_default();
572+ let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();
573+ let cmd = self.ingest_poll(model, parsed);
574+ // Persist cursor and immediately re-poll (the server long-polled 25s).
575+ cmd.and(KeyValue::set(K_CURSOR, json_bytes(&model.cursor)).then_send(Event::Saved))
576+ .and(Command::event(Event::Poll))
577+ .and(render())
578+ }
579+ Event::PollResult(Err(_)) => self.backoff_poll(model),
580+
581+ // ---------------- outbox ----------------
582+ Event::StartFlush => self.flush_next(model),
583+ Event::SendResult(Ok(resp)) => {
584+ let status = u16::from(resp.status());
585+ if status == 401 {
586+ model.token = None;
587+ model.flushing = false;
588+ return Command::event(Event::Authenticate);
589+ }
590+ if (200..300).contains(&status) || status == 400 {
591+ // success, or a permanent rejection (self_send/invalid) — drop it.
592+ model.outbox.pop_front();
593+ return self.persist_outbox(model).and(self.flush_next(model));
594+ }
595+ // transient error — stop; will resume on next connect/poll.
596+ model.flushing = false;
597+ render()
598+ }
599+ Event::SendResult(Err(_)) => {
600+ model.flushing = false;
601+ render()
602+ }
603+ }
604+ }
605+
606+ fn view(&self, model: &Model) -> ViewModel {
607+ let page = match model.page {
608+ Page::Setup => "setup",
609+ Page::Conversations => "conversations",
610+ Page::Chat => "chat",
611+ Page::AddContact => "add_contact",
612+ Page::Settings => "settings",
613+ Page::EditProfile => "edit_profile",
614+ };
615+ let conn = match model.conn {
616+ ConnStatus::Offline => "offline",
617+ ConnStatus::Connecting => "connecting",
618+ ConnStatus::Online => "online",
619+ };
620+
621+ let mut contacts: Vec<ContactVM> = model
622+ .contacts
623+ .values()
624+ .map(|c| {
625+ let msgs = model.messages.get(&c.pubkey);
626+ let last = msgs.and_then(|m| m.last());
627+ ContactVM {
628+ pubkey: c.pubkey.clone(),
629+ name: c.label(),
630+ ob: hex_to_ob(&c.pubkey),
631+ photo: c.photo.clone().unwrap_or_default(),
632+ blocked: c.blocked,
633+ last_message: last.map(|m| m.body.clone()).unwrap_or_default(),
634+ last_ts: last.map_or(0, |m| m.ts),
635+ }
636+ })
637+ .collect();
638+ contacts.sort_by(|a, b| b.last_ts.cmp(&a.last_ts).then(a.name.cmp(&b.name)));
639+
640+ let active_peer = model.active_peer.clone().unwrap_or_default();
641+ let active_contact = model.contacts.get(&active_peer);
642+ let messages: Vec<MessageVM> = model
643+ .messages
644+ .get(&active_peer)
645+ .map(|m| {
646+ m.iter()
647+ .map(|s| MessageVM {
648+ id: s.id.clone(),
649+ body: s.body.clone(),
650+ ts: s.ts,
651+ outgoing: s.outgoing,
652+ delivered: s.delivered,
653+ })
654+ .collect()
655+ })
656+ .unwrap_or_default();
657+
658+ ViewModel {
659+ page: page.to_string(),
660+ has_identity: model.secret_key.is_some(),
661+ my_pubkey_hex: model.my_pubkey.clone(),
662+ my_pubkey_ob: hex_to_ob(&model.my_pubkey),
663+ conn_status: conn.to_string(),
664+ server_url: model.settings.server_url.clone(),
665+ profile: ProfileVM {
666+ display_name: model.profile.display_name.clone(),
667+ bio: model.profile.bio.clone(),
668+ photo: model.profile.photo.clone().unwrap_or_default(),
669+ },
670+ contacts,
671+ active_peer: active_peer.clone(),
672+ active_peer_name: active_contact.map_or_else(
673+ || trunc_ob(&active_peer),
674+ Contact::label,
675+ ),
676+ active_peer_ob: hex_to_ob(&active_peer),
677+ active_peer_blocked: active_contact.is_some_and(|c| c.blocked),
678+ messages,
679+ compose: model.compose.clone(),
680+ error: model.error.clone().unwrap_or_default(),
681+ }
682+ }
683+}
684+
685+impl Skrepka {
686+ fn persist_contacts(&self, model: &Model) -> Command<Effect, Event> {
687+ let list: Vec<Contact> = model.contacts.values().cloned().collect();
688+ KeyValue::set(K_CONTACTS, json_bytes(&list)).then_send(Event::Saved)
689+ }
690+
691+ fn persist_outbox(&self, model: &Model) -> Command<Effect, Event> {
692+ let list: Vec<OutboxItem> = model.outbox.iter().cloned().collect();
693+ KeyValue::set(K_OUTBOX, json_bytes(&list)).then_send(Event::Saved)
694+ }
695+
696+ fn persist_messages(&self, model: &Model, peer: &str) -> Command<Effect, Event> {
697+ let list = model.messages.get(peer).cloned().unwrap_or_default();
698+ KeyValue::set(k_messages(peer), json_bytes(&list)).then_send(Event::Saved)
699+ }
700+
701+ fn schedule_reconnect(&self, model: &mut Model) -> Command<Effect, Event> {
702+ let delay = backoff_ms(model.poll_retries);
703+ model.poll_retries = model.poll_retries.saturating_add(1);
704+ Time::notify_after(Duration::from_millis(delay))
705+ .0
706+ .then_send(|_| Event::Authenticate)
707+ .and(render())
708+ }
709+
710+ fn backoff_poll(&self, model: &mut Model) -> Command<Effect, Event> {
711+ let delay = backoff_ms(model.poll_retries);
712+ model.poll_retries = model.poll_retries.saturating_add(1);
713+ Time::notify_after(Duration::from_millis(delay))
714+ .0
715+ .then_send(|_| Event::Poll)
716+ .and(render())
717+ }
718+
719+ fn send_text(&self, model: &mut Model) -> Command<Effect, Event> {
720+ let Some(peer) = model.active_peer.clone() else {
721+ return render();
722+ };
723+ let body = model.compose.trim().to_string();
724+ if body.is_empty() {
725+ return render();
726+ }
727+ model.compose.clear();
728+ let ts = now_ms();
729+ let id = uuid::Uuid::new_v4().to_string();
730+
731+ let convo = model.messages.entry(peer.clone()).or_default();
732+ let first_to_peer = !convo.iter().any(|m| m.outgoing);
733+ convo.push(StoredMessage {
734+ id: id.clone(),
735+ body: body.clone(),
736+ ts,
737+ outgoing: true,
738+ delivered: false,
739+ });
740+
741+ // Auto-create a bare contact if messaging a brand-new key.
742+ model
743+ .contacts
744+ .entry(peer.clone())
745+ .or_insert_with(|| Contact::new(peer.clone(), String::new(), ts));
746+
747+ let text = Payload::Text { id, body };
748+ model.outbox.push_back(OutboxItem {
749+ recipient: peer.clone(),
750+ envelope_json: protocol::serialize_payload(&text, ts),
751+ });
752+ // On first contact, also share our profile.
753+ if first_to_peer {
754+ let profile = Payload::Profile {
755+ display_name: model.profile.display_name.clone(),
756+ bio: model.profile.bio.clone(),
757+ photo: model.profile.photo.clone(),
758+ };
759+ model.outbox.push_back(OutboxItem {
760+ recipient: peer.clone(),
761+ envelope_json: protocol::serialize_payload(&profile, ts),
762+ });
763+ }
764+
765+ Command::all([
766+ self.persist_messages(model, &peer),
767+ self.persist_contacts(model),
768+ self.persist_outbox(model),
769+ ])
770+ .and(Command::event(Event::StartFlush))
771+ .and(render())
772+ }
773+
774+ fn flush_next(&self, model: &mut Model) -> Command<Effect, Event> {
775+ if model.flushing {
776+ return Command::done();
777+ }
778+ let Some(item) = model.outbox.front().cloned() else {
779+ return Command::done();
780+ };
781+ let (Some(id), Some(token)) = (model.identity(), model.token.clone()) else {
782+ return Command::done();
783+ };
784+ let Ok(recipient) = hex::decode(&item.recipient) else {
785+ // Bad recipient — drop it.
786+ model.outbox.pop_front();
787+ return self.persist_outbox(model).and(self.flush_next(model));
788+ };
789+ let mut recip = [0u8; 32];
790+ if recipient.len() != 32 {
791+ model.outbox.pop_front();
792+ return self.persist_outbox(model).and(self.flush_next(model));
793+ }
794+ recip.copy_from_slice(&recipient);
795+
796+ let mut rng = rand_core::OsRng;
797+ let blob = match crate::crypto::encrypt(&mut rng, &id, &recip, item.envelope_json.as_bytes())
798+ {
799+ Ok(b) => b,
800+ Err(_) => {
801+ model.outbox.pop_front();
802+ return self.persist_outbox(model).and(self.flush_next(model));
803+ }
804+ };
805+
806+ model.flushing = true;
807+ let batch = SendBatch {
808+ messages: vec![Envelope {
809+ to: item.recipient,
810+ encrypted_blob: hex::encode(blob),
811+ }],
812+ };
813+ let url = format!("{}/messages", model.settings.server_url);
814+ match Http::post(url)
815+ .header("authorization", format!("Bearer {token}"))
816+ .body_json(&batch)
817+ {
818+ Ok(req) => req.build().then_send(Event::SendResult),
819+ Err(_) => {
820+ model.flushing = false;
821+ render()
822+ }
823+ }
824+ }
825+
826+ /// Decrypt and fold a poll page into the model; queue delivery acks.
827+ fn ingest_poll(&self, model: &mut Model, page: PollResp) -> Command<Effect, Event> {
828+ if page.cursor > model.cursor {
829+ model.cursor = page.cursor;
830+ }
831+ let Some(id) = model.identity() else {
832+ return Command::done();
833+ };
834+
835+ let mut touched_convos: Vec<String> = Vec::new();
836+ let mut contacts_dirty = false;
837+ // sender hex -> message ids needing acks
838+ let mut ack_targets: BTreeMap<String, Vec<String>> = BTreeMap::new();
839+
840+ for ev in page.events {
841+ let Ok(blob) = hex::decode(&ev.encrypted_blob) else {
842+ continue;
843+ };
844+ let Ok(dec) = crate::crypto::decrypt(&id, &blob) else {
845+ continue;
846+ };
847+ let Some(parsed) = protocol::parse_payload(&dec.plaintext) else {
848+ continue;
849+ };
850+ let sender = dec.sender_hex;
851+ match parsed.payload {
852+ Payload::Text { id: msg_id, body } => {
853+ // auto-create contact
854+ if !model.contacts.contains_key(&sender) {
855+ model
856+ .contacts
857+ .insert(sender.clone(), Contact::new(sender.clone(), String::new(), parsed.ts));
858+ contacts_dirty = true;
859+ }
860+ let convo = model.messages.entry(sender.clone()).or_default();
861+ // dedup by id
862+ if !convo.iter().any(|m| m.id == msg_id) {
863+ convo.push(StoredMessage {
864+ id: msg_id.clone(),
865+ body,
866+ ts: parsed.ts,
867+ outgoing: false,
868+ delivered: false,
869+ });
870+ convo.sort_by_key(|m| m.ts);
871+ if !touched_convos.contains(&sender) {
872+ touched_convos.push(sender.clone());
873+ }
874+ }
875+ ack_targets.entry(sender).or_default().push(msg_id);
876+ }
877+ Payload::DeliveryAck { ack_ids } => {
878+ if let Some(convo) = model.messages.get_mut(&sender) {
879+ let mut changed = false;
880+ for m in convo.iter_mut() {
881+ if m.outgoing && ack_ids.contains(&m.id) && !m.delivered {
882+ m.delivered = true;
883+ changed = true;
884+ }
885+ }
886+ if changed && !touched_convos.contains(&sender) {
887+ touched_convos.push(sender);
888+ }
889+ }
890+ }
891+ Payload::Profile {
892+ display_name,
893+ bio,
894+ photo,
895+ } => {
896+ let c = model
897+ .contacts
898+ .entry(sender.clone())
899+ .or_insert_with(|| Contact::new(sender.clone(), String::new(), parsed.ts));
900+ // Ignore stale profile replays (PROTOCOL.md §4).
901+ if parsed.ts >= c.last_profile_ts {
902+ c.display_name = display_name;
903+ c.bio = bio;
904+ c.photo = photo;
905+ c.last_profile_ts = parsed.ts;
906+ contacts_dirty = true;
907+ }
908+ }
909+ }
910+ }
911+
912+ // Queue delivery acks (one payload per sender).
913+ let ack_ts = now_ms();
914+ for (sender, ids) in ack_targets {
915+ let payload = Payload::DeliveryAck { ack_ids: ids };
916+ model.outbox.push_back(OutboxItem {
917+ recipient: sender,
918+ envelope_json: protocol::serialize_payload(&payload, ack_ts),
919+ });
920+ }
921+
922+ // Persist everything touched.
923+ let mut cmds: Vec<Command<Effect, Event>> = Vec::new();
924+ for peer in &touched_convos {
925+ cmds.push(self.persist_messages(model, peer));
926+ }
927+ if contacts_dirty {
928+ cmds.push(self.persist_contacts(model));
929+ }
930+ cmds.push(self.persist_outbox(model));
931+ cmds.push(Command::event(Event::StartFlush));
932+ Command::all(cmds)
933+ }
934+}
935+
936+fn backoff_ms(retries: u32) -> u64 {
937+ (3000u64.saturating_mul(1u64 << retries.min(4))).min(30_000)
938+}
939+
940+/// Decode a kv `get` result into a typed value, ignoring errors / absence.
941+fn parse_kv<T: for<'de> Deserialize<'de>>(res: KvData) -> Option<T> {
942+ let bytes = res.ok().flatten()?;
943+ serde_json::from_slice(&bytes).ok()
944+}
945+
946+#[cfg(test)]
947+mod tests {
948+ use super::*;
949+ use crate::crypto::Identity;
950+
951+ fn with_identity() -> Model {
952+ let id = Identity::from_seed(&[3u8; 32]);
953+ let mut m = Model::default();
954+ m.secret_key = Some(id.secret_key.to_vec());
955+ m.my_pubkey = id.public_key_hex();
956+ m
957+ }
958+
959+ fn peer_hex(seed: u8) -> String {
960+ Identity::from_seed(&[seed; 32]).public_key_hex()
961+ }
962+
963+ #[test]
964+ fn server_host_strips_scheme_port_and_case() {
965+ assert_eq!(server_host("https://Relay.Example.com:8443/x"), "relay.example.com");
966+ assert_eq!(server_host("http://localhost:8080"), "localhost");
967+ assert_eq!(server_host("relay.example.com."), "relay.example.com");
968+ }
969+
970+ #[test]
971+ fn backoff_is_capped() {
972+ assert_eq!(backoff_ms(0), 3000);
973+ assert_eq!(backoff_ms(1), 6000);
974+ assert_eq!(backoff_ms(3), 24000);
975+ assert_eq!(backoff_ms(10), 30000);
976+ }
977+
978+ #[test]
979+ fn add_contact_accepts_hex_and_rejects_self() {
980+ let app = Skrepka;
981+ let mut m = with_identity();
982+ let peer = peer_hex(9);
983+ let _ = app.update(
984+ Event::AddContact {
985+ input: peer.clone(),
986+ nickname: "Bob".into(),
987+ },
988+ &mut m,
989+ );
990+ assert!(m.contacts.contains_key(&peer));
991+ assert_eq!(m.contacts[&peer].nickname, "Bob");
992+
993+ // adding own key is rejected with an error
994+ let mine = m.my_pubkey.clone();
995+ let _ = app.update(
996+ Event::AddContact {
997+ input: mine,
998+ nickname: String::new(),
999+ },
1000+ &mut m,
1001+ );
1002+ assert!(m.error.is_some());
1003+ }
1004+
1005+ #[test]
1006+ fn send_text_enqueues_message_profile_and_outbox() {
1007+ let app = Skrepka;
1008+ let mut m = with_identity();
1009+ let peer = peer_hex(9);
1010+ m.active_peer = Some(peer.clone());
1011+ m.compose = "hello".into();
1012+
1013+ let _ = app.update(Event::SendText, &mut m);
1014+
1015+ // one outgoing message recorded
1016+ let convo = &m.messages[&peer];
1017+ assert_eq!(convo.len(), 1);
1018+ assert!(convo[0].outgoing);
1019+ assert_eq!(convo[0].body, "hello");
1020+ // compose cleared, contact auto-created
1021+ assert!(m.compose.is_empty());
1022+ assert!(m.contacts.contains_key(&peer));
1023+ // outbox has the text + a first-contact profile
1024+ assert_eq!(m.outbox.len(), 2);
1025+ assert_eq!(m.outbox[0].recipient, peer);
1026+ }
1027+
1028+ #[test]
1029+ fn send_text_without_peer_or_empty_is_noop() {
1030+ let app = Skrepka;
1031+ let mut m = with_identity();
1032+ m.compose = "x".into();
1033+ let _ = app.update(Event::SendText, &mut m); // no active peer
1034+ assert!(m.outbox.is_empty());
1035+
1036+ m.active_peer = Some(peer_hex(9));
1037+ m.compose = " ".into();
1038+ let _ = app.update(Event::SendText, &mut m); // whitespace only
1039+ assert!(m.outbox.is_empty());
1040+ }
1041+
1042+ #[test]
1043+ fn set_blocked_toggles_and_view_reflects_it() {
1044+ let app = Skrepka;
1045+ let mut m = with_identity();
1046+ let peer = peer_hex(9);
1047+ m.contacts
1048+ .insert(peer.clone(), Contact::new(peer.clone(), "Bob".into(), 0));
1049+ let _ = app.update(
1050+ Event::SetBlocked {
1051+ peer: peer.clone(),
1052+ blocked: true,
1053+ },
1054+ &mut m,
1055+ );
1056+ assert!(m.contacts[&peer].blocked);
1057+ let vm = app.view(&m);
1058+ assert!(vm.contacts.iter().any(|c| c.pubkey == peer && c.blocked));
1059+ }
1060+
1061+ #[test]
1062+ fn view_exposes_identity_and_ob() {
1063+ let app = Skrepka;
1064+ let m = with_identity();
1065+ let vm = app.view(&m);
1066+ assert!(vm.has_identity);
1067+ assert_eq!(vm.my_pubkey_hex, m.my_pubkey);
1068+ assert_eq!(vm.my_pubkey_ob.split('-').count(), 16);
1069+ }
1070+}
core/src/bin/codegen.rsnew file+41
@@ -0,0 +1,41 @@
1+//! Generates the Swift (and other) type bindings for the shell from the core's
2+//! Event / ViewModel / Effect types via facet typegen.
3+
4+use std::path::PathBuf;
5+
6+use anyhow::Result;
7+use clap::{Parser, ValueEnum};
8+use crux_core::type_generation::facet::{Config, TypeRegistry};
9+use log::info;
10+
11+use skrepka_core::Skrepka;
12+
13+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
14+enum Language {
15+ Swift,
16+}
17+
18+#[derive(Parser)]
19+#[command(version, about, long_about = None)]
20+struct Args {
21+ #[arg(short, long, value_enum)]
22+ language: Language,
23+ #[arg(short, long)]
24+ output_dir: PathBuf,
25+}
26+
27+fn main() -> Result<()> {
28+ pretty_env_logger::init();
29+ let args = Args::parse();
30+
31+ let typegen = TypeRegistry::new().register_app::<Skrepka>()?.build()?;
32+ let config = Config::builder("Skrepka", &args.output_dir).build();
33+
34+ match args.language {
35+ Language::Swift => {
36+ info!("Generating Swift types");
37+ typegen.swift(&config)?;
38+ }
39+ }
40+ Ok(())
41+}
core/src/crypto.rsnew file+359
@@ -0,0 +1,359 @@
1+//! Byte-exact port of the skrepka message crypto (see PROTOCOL.md §3).
2+//!
3+//! Per-message: ephemeral X25519, HKDF-SHA256, XChaCha20-Poly1305, Ed25519
4+//! signature bound to the recipient, zstd-compressed plaintext.
5+//!
6+//! The Ed25519 secret key is the 64-byte libsodium form: `seed(32) || pub(32)`.
7+//! The X25519 keys are derived from Ed25519 the same way libsodium's
8+//! `crypto_sign_ed25519_{pk,sk}_to_curve25519` do, so blobs interoperate with
9+//! any client following the spec.
10+
11+use chacha20poly1305::aead::Aead;
12+use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
13+use curve25519_dalek::edwards::CompressedEdwardsY;
14+use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
15+use hkdf::Hkdf;
16+use rand_core::{CryptoRng, RngCore};
17+use sha2::{Digest, Sha256, Sha512};
18+
19+/// HKDF `info` string (PROTOCOL.md §3 / Constants.hkdfInfo). Fixed.
20+const HKDF_INFO: &[u8] = b"skrepka-v1";
21+/// Domain-separation tag for the auth challenge signature (PROTOCOL.md §6).
22+const AUTH_TAG: &str = "skrepka-auth-v1:";
23+
24+pub const ED25519_SECRET_LEN: usize = 64;
25+pub const ED25519_PUBLIC_LEN: usize = 32;
26+pub const NONCE_LEN: usize = 24;
27+/// 32 (eph) + 24 (nonce) + 32 (sender pub) + 64 (sig) + 16 (AEAD tag).
28+pub const MIN_BLOB_LEN: usize = 32 + NONCE_LEN + 32 + 64 + 16;
29+
30+#[derive(Debug, Clone, PartialEq, Eq)]
31+pub enum CryptoError {
32+ BadKeyLength,
33+ BlobTooShort,
34+ Decrypt,
35+ BadSignature,
36+ Decompress,
37+}
38+
39+impl std::fmt::Display for CryptoError {
40+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41+ let s = match self {
42+ CryptoError::BadKeyLength => "bad key length",
43+ CryptoError::BlobTooShort => "blob too short",
44+ CryptoError::Decrypt => "decryption failed",
45+ CryptoError::BadSignature => "signature verification failed",
46+ CryptoError::Decompress => "decompression failed",
47+ };
48+ f.write_str(s)
49+ }
50+}
51+
52+impl std::error::Error for CryptoError {}
53+
54+/// A generated identity: the 64-byte libsodium-form secret key. The public key
55+/// is the last 32 bytes.
56+#[derive(Clone)]
57+pub struct Identity {
58+ pub secret_key: [u8; ED25519_SECRET_LEN],
59+}
60+
61+impl Identity {
62+ /// Build from a 64-byte secret key (e.g. loaded from the Keychain).
63+ pub fn from_secret_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
64+ if bytes.len() != ED25519_SECRET_LEN {
65+ return Err(CryptoError::BadKeyLength);
66+ }
67+ let mut secret_key = [0u8; ED25519_SECRET_LEN];
68+ secret_key.copy_from_slice(bytes);
69+ Ok(Identity { secret_key })
70+ }
71+
72+ /// Generate a fresh identity from a CSPRNG.
73+ pub fn generate(rng: &mut (impl RngCore + CryptoRng)) -> Self {
74+ let mut seed = [0u8; 32];
75+ rng.fill_bytes(&mut seed);
76+ Self::from_seed(&seed)
77+ }
78+
79+ /// Build the 64-byte secret key (`seed || pub`) from a 32-byte seed.
80+ pub fn from_seed(seed: &[u8; 32]) -> Self {
81+ let signing = SigningKey::from_bytes(seed);
82+ let public = signing.verifying_key();
83+ let mut secret_key = [0u8; ED25519_SECRET_LEN];
84+ secret_key[..32].copy_from_slice(seed);
85+ secret_key[32..].copy_from_slice(public.as_bytes());
86+ Identity { secret_key }
87+ }
88+
89+ pub fn public_key(&self) -> [u8; ED25519_PUBLIC_LEN] {
90+ let mut pk = [0u8; ED25519_PUBLIC_LEN];
91+ pk.copy_from_slice(&self.secret_key[32..]);
92+ pk
93+ }
94+
95+ pub fn public_key_hex(&self) -> String {
96+ hex::encode(self.public_key())
97+ }
98+
99+ fn signing_key(&self) -> SigningKey {
100+ let mut seed = [0u8; 32];
101+ seed.copy_from_slice(&self.secret_key[..32]);
102+ SigningKey::from_bytes(&seed)
103+ }
104+
105+ /// Sign the auth challenge: `ed25519(sk, "skrepka-auth-v1:" + host + ":" + challenge)`.
106+ /// `server_host` must already be the bare lowercased hostname.
107+ pub fn sign_challenge(&self, server_host: &str, challenge: &str) -> String {
108+ let message = format!("{AUTH_TAG}{server_host}:{challenge}");
109+ let sig = self.signing_key().sign(message.as_bytes());
110+ hex::encode(sig.to_bytes())
111+ }
112+}
113+
114+/// libsodium `crypto_sign_ed25519_pk_to_curve25519`: Edwards Y -> Montgomery u.
115+fn ed25519_pk_to_x25519(ed_pub: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
116+ let compressed = CompressedEdwardsY(*ed_pub);
117+ let point = compressed.decompress().ok_or(CryptoError::Decrypt)?;
118+ Ok(point.to_montgomery().to_bytes())
119+}
120+
121+/// libsodium `crypto_sign_ed25519_sk_to_curve25519`: clamp(SHA-512(seed)[..32]).
122+fn ed25519_sk_to_x25519(secret_key: &[u8; 64]) -> [u8; 32] {
123+ let mut h = Sha512::new();
124+ h.update(&secret_key[..32]);
125+ let digest = h.finalize();
126+ let mut scalar = [0u8; 32];
127+ scalar.copy_from_slice(&digest[..32]);
128+ // X25519 clamp.
129+ scalar[0] &= 248;
130+ scalar[31] &= 127;
131+ scalar[31] |= 64;
132+ scalar
133+}
134+
135+/// Raw X25519 scalar multiplication (no hashing), as libsodium `crypto_scalarmult`.
136+fn x25519(scalar: &[u8; 32], point: &[u8; 32]) -> [u8; 32] {
137+ x25519_dalek::x25519(*scalar, *point)
138+}
139+
140+/// HKDF-SHA256(ikm=raw_secret, salt=eph_pub || recip_x_pub, info="skrepka-v1", 32).
141+fn derive_key(raw_secret: &[u8; 32], eph_pub: &[u8; 32], recip_x_pub: &[u8; 32]) -> [u8; 32] {
142+ let mut salt = [0u8; 64];
143+ salt[..32].copy_from_slice(eph_pub);
144+ salt[32..].copy_from_slice(recip_x_pub);
145+ let hk = Hkdf::<Sha256>::new(Some(&salt), raw_secret);
146+ let mut okm = [0u8; 32];
147+ hk.expand(HKDF_INFO, &mut okm).expect("32 is a valid length");
148+ okm
149+}
150+
151+fn compress(plaintext: &[u8]) -> Vec<u8> {
152+ // zstd frame with embedded content size; decodable without a capacity hint.
153+ zstd::encode_all(plaintext, 0).expect("zstd compression is infallible for in-memory input")
154+}
155+
156+fn decompress(data: &[u8]) -> Result<Vec<u8>, CryptoError> {
157+ zstd::decode_all(data).map_err(|_| CryptoError::Decompress)
158+}
159+
160+/// Encrypt `plaintext` for `recipient_ed_pub` (32-byte Ed25519 pubkey).
161+/// Returns the on-wire blob: `eph_pub(32) || nonce(24) || ciphertext`.
162+pub fn encrypt(
163+ rng: &mut (impl RngCore + CryptoRng),
164+ sender: &Identity,
165+ recipient_ed_pub: &[u8; 32],
166+ plaintext: &[u8],
167+) -> Result<Vec<u8>, CryptoError> {
168+ let sender_pub = sender.public_key();
169+
170+ // Ephemeral raw X25519 keypair (libsodium crypto_box_keypair).
171+ let mut eph_priv = [0u8; 32];
172+ rng.fill_bytes(&mut eph_priv);
173+ let eph_pub = x25519_dalek::x25519(eph_priv, x25519_dalek::X25519_BASEPOINT_BYTES);
174+
175+ let recip_x = ed25519_pk_to_x25519(recipient_ed_pub)?;
176+ let raw_secret = x25519(&eph_priv, &recip_x);
177+ let key = derive_key(&raw_secret, &eph_pub, &recip_x);
178+
179+ let compressed = compress(plaintext);
180+
181+ // Sign recipient_pub || compressed (binds the blob to the recipient).
182+ let mut signed = Vec::with_capacity(32 + compressed.len());
183+ signed.extend_from_slice(recipient_ed_pub);
184+ signed.extend_from_slice(&compressed);
185+ let signature = sender.signing_key().sign(&signed);
186+
187+ // inner = sender_pub(32) || sig(64) || compressed
188+ let mut inner = Vec::with_capacity(32 + 64 + compressed.len());
189+ inner.extend_from_slice(&sender_pub);
190+ inner.extend_from_slice(&signature.to_bytes());
191+ inner.extend_from_slice(&compressed);
192+
193+ let mut nonce = [0u8; NONCE_LEN];
194+ rng.fill_bytes(&mut nonce);
195+ let cipher = XChaCha20Poly1305::new(&key.into());
196+ let ciphertext = cipher
197+ .encrypt(XNonce::from_slice(&nonce), inner.as_ref())
198+ .map_err(|_| CryptoError::Decrypt)?;
199+
200+ let mut blob = Vec::with_capacity(32 + NONCE_LEN + ciphertext.len());
201+ blob.extend_from_slice(&eph_pub);
202+ blob.extend_from_slice(&nonce);
203+ blob.extend_from_slice(&ciphertext);
204+ Ok(blob)
205+}
206+
207+/// Result of decrypting a blob: the plaintext and the sender's Ed25519 pubkey (hex).
208+#[derive(Debug, Clone)]
209+pub struct Decrypted {
210+ pub plaintext: Vec<u8>,
211+ pub sender_hex: String,
212+}
213+
214+/// Decrypt a blob addressed to `recipient`. Verifies the sender's signature.
215+pub fn decrypt(recipient: &Identity, blob: &[u8]) -> Result<Decrypted, CryptoError> {
216+ if blob.len() < MIN_BLOB_LEN {
217+ return Err(CryptoError::BlobTooShort);
218+ }
219+ let mut eph_pub = [0u8; 32];
220+ eph_pub.copy_from_slice(&blob[..32]);
221+ let mut nonce = [0u8; NONCE_LEN];
222+ nonce.copy_from_slice(&blob[32..32 + NONCE_LEN]);
223+ let ciphertext = &blob[32 + NONCE_LEN..];
224+
225+ let recip_x_priv = ed25519_sk_to_x25519(&recipient.secret_key);
226+ let raw_secret = x25519(&recip_x_priv, &eph_pub);
227+ let recip_x_pub = ed25519_pk_to_x25519(&recipient.public_key())?;
228+ let key = derive_key(&raw_secret, &eph_pub, &recip_x_pub);
229+
230+ let cipher = XChaCha20Poly1305::new(&key.into());
231+ let inner = cipher
232+ .decrypt(XNonce::from_slice(&nonce), ciphertext)
233+ .map_err(|_| CryptoError::Decrypt)?;
234+
235+ if inner.len() < 32 + 64 {
236+ return Err(CryptoError::BlobTooShort);
237+ }
238+ let mut sender_pub = [0u8; 32];
239+ sender_pub.copy_from_slice(&inner[..32]);
240+ let mut sig_bytes = [0u8; 64];
241+ sig_bytes.copy_from_slice(&inner[32..96]);
242+ let compressed = &inner[96..];
243+
244+ // Verify signature over recipient_pub || compressed.
245+ let verifying = VerifyingKey::from_bytes(&sender_pub).map_err(|_| CryptoError::BadSignature)?;
246+ let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
247+ let mut signed = Vec::with_capacity(32 + compressed.len());
248+ signed.extend_from_slice(&recipient.public_key());
249+ signed.extend_from_slice(compressed);
250+ verifying
251+ .verify(&signed, &signature)
252+ .map_err(|_| CryptoError::BadSignature)?;
253+
254+ let plaintext = decompress(compressed)?;
255+ Ok(Decrypted {
256+ plaintext,
257+ sender_hex: hex::encode(sender_pub),
258+ })
259+}
260+
261+#[cfg(test)]
262+mod tests {
263+ use super::*;
264+ use rand_chacha::rand_core::SeedableRng;
265+ use rand_chacha::ChaCha20Rng;
266+
267+ fn rng(seed: u64) -> ChaCha20Rng {
268+ ChaCha20Rng::seed_from_u64(seed)
269+ }
270+
271+ #[test]
272+ fn identity_pub_is_last_32_bytes_and_consistent() {
273+ let id = Identity::generate(&mut rng(1));
274+ assert_eq!(&id.secret_key[32..], &id.public_key());
275+ // Re-deriving from the seed yields the same public key.
276+ let mut seed = [0u8; 32];
277+ seed.copy_from_slice(&id.secret_key[..32]);
278+ let id2 = Identity::from_seed(&seed);
279+ assert_eq!(id.public_key(), id2.public_key());
280+ }
281+
282+ #[test]
283+ fn round_trip_recovers_plaintext_and_sender() {
284+ let alice = Identity::generate(&mut rng(10));
285+ let bob = Identity::generate(&mut rng(20));
286+ let msg = br#"{"type":"text","id":"abc","body":"hello bob","ts":1700000000000}"#;
287+
288+ let blob = encrypt(&mut rng(99), &alice, &bob.public_key(), msg).unwrap();
289+ assert!(blob.len() >= MIN_BLOB_LEN);
290+
291+ let out = decrypt(&bob, &blob).unwrap();
292+ assert_eq!(out.plaintext, msg);
293+ assert_eq!(out.sender_hex, alice.public_key_hex());
294+ }
295+
296+ #[test]
297+ fn wrong_recipient_cannot_decrypt() {
298+ let alice = Identity::generate(&mut rng(1));
299+ let bob = Identity::generate(&mut rng(2));
300+ let eve = Identity::generate(&mut rng(3));
301+ let blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
302+ assert_eq!(decrypt(&eve, &blob).unwrap_err(), CryptoError::Decrypt);
303+ }
304+
305+ #[test]
306+ fn tampered_ciphertext_is_rejected() {
307+ let alice = Identity::generate(&mut rng(1));
308+ let bob = Identity::generate(&mut rng(2));
309+ let mut blob = encrypt(&mut rng(4), &alice, &bob.public_key(), b"secret").unwrap();
310+ let last = blob.len() - 1;
311+ blob[last] ^= 0xff;
312+ assert!(decrypt(&bob, &blob).is_err());
313+ }
314+
315+ #[test]
316+ fn blob_layout_offsets() {
317+ let alice = Identity::generate(&mut rng(1));
318+ let bob = Identity::generate(&mut rng(2));
319+ let blob = encrypt(&mut rng(7), &alice, &bob.public_key(), b"x").unwrap();
320+ // eph_pub occupies the first 32 bytes and equals x25519 basepoint * eph_priv;
321+ // we can't recompute eph_priv, but the nonce region must differ run-to-run.
322+ let blob2 = encrypt(&mut rng(8), &alice, &bob.public_key(), b"x").unwrap();
323+ assert_ne!(&blob[..32], &blob2[..32], "ephemeral pubkey must be random");
324+ assert_ne!(
325+ &blob[32..32 + NONCE_LEN],
326+ &blob2[32..32 + NONCE_LEN],
327+ "nonce must be random"
328+ );
329+ }
330+
331+ #[test]
332+ fn too_short_blob_errors() {
333+ let bob = Identity::generate(&mut rng(2));
334+ assert_eq!(
335+ decrypt(&bob, &[0u8; 10]).unwrap_err(),
336+ CryptoError::BlobTooShort
337+ );
338+ }
339+
340+ #[test]
341+ fn sk_to_x25519_clamps() {
342+ let id = Identity::generate(&mut rng(5));
343+ let scalar = ed25519_sk_to_x25519(&id.secret_key);
344+ assert_eq!(scalar[0] & 7, 0);
345+ assert_eq!(scalar[31] & 64, 64);
346+ assert_eq!(scalar[31] & 128, 0);
347+ }
348+
349+ #[test]
350+ fn challenge_signature_is_deterministic_and_host_bound() {
351+ let id = Identity::from_seed(&[7u8; 32]);
352+ let a = id.sign_challenge("relay.example.com", "deadbeef");
353+ let b = id.sign_challenge("relay.example.com", "deadbeef");
354+ let c = id.sign_challenge("other.example.com", "deadbeef");
355+ assert_eq!(a, b, "Ed25519 is deterministic");
356+ assert_ne!(a, c, "signature is bound to the host");
357+ assert_eq!(a.len(), 128, "64-byte sig in hex");
358+ }
359+}
core/src/ffi.rsnew file+62
@@ -0,0 +1,62 @@
1+//! The FFI surface the Swift shell calls, via BoltFFI. Mirrors the Crux
2+//! counter example's `ffi.rs`: a `Bridge` that takes/returns bincode bytes.
3+
4+#![allow(clippy::used_underscore_items)]
5+
6+use crux_core::{
7+ bridge::{Bridge, EffectId},
8+ Core,
9+};
10+
11+use crate::Skrepka;
12+
13+/// The main interface used by the shell.
14+pub struct CoreFFI {
15+ core: Bridge<Skrepka>,
16+}
17+
18+impl Default for CoreFFI {
19+ fn default() -> Self {
20+ Self::new()
21+ }
22+}
23+
24+#[boltffi::export]
25+impl CoreFFI {
26+ #[must_use]
27+ pub fn new() -> Self {
28+ Self {
29+ core: Bridge::new(Core::new()),
30+ }
31+ }
32+
33+ /// Send an event to the app and return the resulting effects (bincode).
34+ #[must_use]
35+ pub fn update(&self, data: &[u8]) -> Vec<u8> {
36+ let mut effects = Vec::new();
37+ match self.core.update(data, &mut effects) {
38+ Ok(()) => effects,
39+ Err(e) => panic!("{e}"),
40+ }
41+ }
42+
43+ /// Resolve an effect request and return the next batch of effects.
44+ #[must_use]
45+ pub fn resolve(&self, id: u32, data: &[u8]) -> Vec<u8> {
46+ let mut effects = Vec::new();
47+ match self.core.resolve(EffectId(id), data, &mut effects) {
48+ Ok(()) => effects,
49+ Err(e) => panic!("{e}"),
50+ }
51+ }
52+
53+ /// Get the current serialized `ViewModel`.
54+ #[must_use]
55+ pub fn view(&self) -> Vec<u8> {
56+ let mut view_model = Vec::new();
57+ match self.core.view(&mut view_model) {
58+ Ok(()) => view_model,
59+ Err(e) => panic!("{e}"),
60+ }
61+ }
62+}
core/src/lib.rsnew file+10
@@ -0,0 +1,10 @@
1+//! Skrepka shared core — portable business logic for the iOS (and future) clients.
2+
3+pub mod app;
4+pub mod crypto;
5+pub mod ffi;
6+pub mod model;
7+pub mod phonemic;
8+pub mod protocol;
9+
10+pub use app::Skrepka;
core/src/model.rsnew file+173
@@ -0,0 +1,173 @@
1+//! Domain types: persisted state (stored in the kv capability as JSON) and the
2+//! `ViewModel` types the Swift shell renders.
3+
4+use facet::Facet;
5+use serde::{Deserialize, Serialize};
6+
7+use crate::phonemic;
8+
9+// ---------------------------------------------------------------------------
10+// Persisted state (serialized to the kv store as JSON)
11+// ---------------------------------------------------------------------------
12+
13+pub const DEFAULT_SERVER_URL: &str = "http://localhost:8080";
14+
15+#[derive(Serialize, Deserialize, Clone, Debug)]
16+pub struct Settings {
17+ pub server_url: String,
18+}
19+
20+impl Default for Settings {
21+ fn default() -> Self {
22+ Settings {
23+ server_url: DEFAULT_SERVER_URL.to_string(),
24+ }
25+ }
26+}
27+
28+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
29+pub struct OwnProfile {
30+ pub display_name: String,
31+ pub bio: String,
32+ /// base64-encoded image, or `None`.
33+ pub photo: Option<String>,
34+}
35+
36+#[derive(Serialize, Deserialize, Clone, Debug)]
37+pub struct Contact {
38+ pub pubkey: String,
39+ pub nickname: String,
40+ pub display_name: String,
41+ pub bio: String,
42+ pub photo: Option<String>,
43+ pub blocked: bool,
44+ /// `ts` of the last applied profile message (drop older replays).
45+ pub last_profile_ts: i64,
46+ pub added_at: i64,
47+}
48+
49+impl Contact {
50+ pub fn new(pubkey: String, nickname: String, added_at: i64) -> Self {
51+ Contact {
52+ pubkey,
53+ nickname,
54+ display_name: String::new(),
55+ bio: String::new(),
56+ photo: None,
57+ blocked: false,
58+ last_profile_ts: 0,
59+ added_at,
60+ }
61+ }
62+
63+ /// Best human label: nickname, else shared display name, else truncated @p.
64+ pub fn label(&self) -> String {
65+ if !self.nickname.is_empty() {
66+ self.nickname.clone()
67+ } else if !self.display_name.is_empty() {
68+ self.display_name.clone()
69+ } else {
70+ trunc_ob(&self.pubkey)
71+ }
72+ }
73+}
74+
75+#[derive(Serialize, Deserialize, Clone, Debug)]
76+pub struct StoredMessage {
77+ pub id: String,
78+ pub body: String,
79+ pub ts: i64,
80+ pub outgoing: bool,
81+ /// Only meaningful for outgoing messages.
82+ pub delivered: bool,
83+}
84+
85+/// A queued outbound payload awaiting encryption + send.
86+#[derive(Serialize, Deserialize, Clone, Debug)]
87+pub struct OutboxItem {
88+ pub recipient: String,
89+ /// The serialized plaintext payload JSON (encrypted only at send time).
90+ pub envelope_json: String,
91+}
92+
93+// ---------------------------------------------------------------------------
94+// @p helpers
95+// ---------------------------------------------------------------------------
96+
97+/// Full @p rendering of a hex pubkey.
98+pub fn hex_to_ob(hex_key: &str) -> String {
99+ match hex::decode(hex_key) {
100+ Ok(bytes) => phonemic::to_ob(&bytes),
101+ Err(_) => hex_key.to_string(),
102+ }
103+}
104+
105+/// Truncated @p: first two and last two syllables (`a-b-…-y-z`).
106+pub fn trunc_ob(hex_key: &str) -> String {
107+ let ob = hex_to_ob(hex_key);
108+ let parts: Vec<&str> = ob.split('-').collect();
109+ if parts.len() > 4 {
110+ format!(
111+ "{}-{}-…-{}-{}",
112+ parts[0],
113+ parts[1],
114+ parts[parts.len() - 2],
115+ parts[parts.len() - 1]
116+ )
117+ } else {
118+ ob
119+ }
120+}
121+
122+// ---------------------------------------------------------------------------
123+// ViewModel (rendered by the shell)
124+// ---------------------------------------------------------------------------
125+
126+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
127+pub struct ProfileVM {
128+ pub display_name: String,
129+ pub bio: String,
130+ /// base64 image or empty.
131+ pub photo: String,
132+}
133+
134+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
135+pub struct ContactVM {
136+ pub pubkey: String,
137+ pub name: String,
138+ pub ob: String,
139+ pub photo: String,
140+ pub blocked: bool,
141+ pub last_message: String,
142+ pub last_ts: i64,
143+}
144+
145+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
146+pub struct MessageVM {
147+ pub id: String,
148+ pub body: String,
149+ pub ts: i64,
150+ pub outgoing: bool,
151+ pub delivered: bool,
152+}
153+
154+#[derive(Facet, Serialize, Deserialize, Clone, Default, Debug)]
155+pub struct ViewModel {
156+ /// "setup" | "conversations" | "chat" | "add_contact" | "settings" | "edit_profile"
157+ pub page: String,
158+ pub has_identity: bool,
159+ pub my_pubkey_hex: String,
160+ pub my_pubkey_ob: String,
161+ /// "offline" | "connecting" | "online"
162+ pub conn_status: String,
163+ pub server_url: String,
164+ pub profile: ProfileVM,
165+ pub contacts: Vec<ContactVM>,
166+ pub active_peer: String,
167+ pub active_peer_name: String,
168+ pub active_peer_ob: String,
169+ pub active_peer_blocked: bool,
170+ pub messages: Vec<MessageVM>,
171+ pub compose: String,
172+ pub error: String,
173+}
core/src/phonemic.rsnew file+153
@@ -0,0 +1,153 @@
1+//! Urbit-style `@p` syllable encoding for public keys (port of Phonemic.fs).
2+//!
3+//! Each byte pair `[hi, lo]` encodes as `prefixes[hi] ++ suffixes[lo]` (6 chars);
4+//! pairs are joined with `-`. A 32-byte key -> 16 six-char syllables.
5+
6+const PREFIXES: [&str; 256] = [
7+ "doz", "mar", "bin", "wan", "sam", "lit", "sig", "hid", "fid", "lis", "sog", "dir", "wac",
8+ "sab", "wis", "sib", "rig", "sol", "dop", "mod", "fog", "lid", "hop", "dar", "dor", "lor",
9+ "hod", "fol", "rin", "tog", "sil", "mir", "hol", "pas", "lac", "rov", "liv", "dal", "sat",
10+ "lib", "tab", "han", "tic", "pid", "tor", "bol", "fos", "dot", "los", "dil", "for", "pil",
11+ "ram", "tir", "win", "tad", "bic", "dif", "roc", "wid", "bis", "das", "mid", "lop", "ril",
12+ "nar", "dap", "mol", "san", "loc", "nov", "sit", "nid", "tip", "sic", "rop", "wit", "nat",
13+ "pan", "min", "rit", "pod", "mot", "tam", "tol", "sav", "pos", "nap", "nop", "som", "fin",
14+ "fon", "ban", "mor", "wor", "sip", "ron", "nor", "bot", "wic", "soc", "wat", "dol", "mag",
15+ "pic", "dav", "bid", "bal", "tim", "tas", "mal", "lig", "siv", "tag", "pad", "sal", "div",
16+ "dac", "tan", "sid", "fab", "tar", "mon", "ran", "nis", "wol", "mis", "pal", "las", "dis",
17+ "map", "rab", "tob", "rol", "lat", "lon", "nod", "nav", "fig", "nom", "nib", "pag", "sop",
18+ "ral", "bil", "had", "doc", "rid", "moc", "pac", "rav", "rip", "fal", "tod", "til", "tin",
19+ "hap", "mic", "fan", "pat", "tac", "lab", "mog", "sim", "son", "pin", "lom", "ric", "tap",
20+ "fir", "has", "bos", "bat", "poc", "hac", "tid", "hav", "sap", "lin", "dib", "hos", "dab",
21+ "bit", "bar", "rac", "par", "lod", "dos", "bor", "toc", "hil", "mac", "tom", "dig", "fil",
22+ "fas", "mit", "hob", "har", "mig", "hin", "rad", "mas", "hal", "rag", "lag", "fad", "top",
23+ "mop", "hab", "nil", "nos", "mil", "fop", "fam", "dat", "nol", "din", "hat", "nac", "ris",
24+ "fot", "rib", "hoc", "nim", "lar", "fit", "wal", "rap", "sar", "nal", "mos", "lan", "don",
25+ "dan", "lad", "dov", "riv", "bac", "pol", "lap", "tal", "pit", "nam", "bon", "ros", "ton",
26+ "fod", "pon", "sov", "noc", "sor", "lav", "mat", "mip", "fip",
27+];
28+
29+const SUFFIXES: [&str; 256] = [
30+ "zod", "nec", "bud", "wes", "sev", "per", "sut", "let", "ful", "pen", "syt", "dur", "wep",
31+ "ser", "wyl", "sun", "ryp", "syx", "dyr", "nup", "heb", "peg", "lup", "dep", "dys", "put",
32+ "lug", "hec", "ryt", "tyv", "syd", "nex", "lun", "mep", "lut", "sep", "pes", "del", "sul",
33+ "ped", "tem", "led", "tul", "met", "wen", "byn", "hex", "feb", "pyl", "dul", "het", "mev",
34+ "rut", "tyl", "wyd", "tep", "bes", "dex", "sef", "wyc", "bur", "der", "nep", "pur", "rys",
35+ "reb", "den", "nut", "sub", "pet", "rul", "syn", "reg", "tyd", "sup", "sem", "wyn", "rec",
36+ "meg", "net", "sec", "mul", "nym", "tev", "web", "sum", "mut", "nyx", "rex", "teb", "fus",
37+ "hep", "ben", "mus", "wyx", "sym", "sel", "ruc", "dec", "wex", "syr", "wet", "dyl", "myn",
38+ "mes", "det", "bet", "bel", "tux", "tug", "myr", "pel", "syp", "ter", "meb", "set", "dut",
39+ "deg", "tex", "sur", "fel", "tud", "nux", "rux", "ren", "wyt", "nub", "med", "lyt", "dus",
40+ "neb", "rum", "tyn", "seg", "lyx", "pun", "res", "red", "fun", "rev", "ref", "mec", "ted",
41+ "rus", "bex", "leb", "dux", "ryn", "num", "pyx", "ryg", "ryx", "fep", "tyr", "tus", "tyc",
42+ "leg", "nem", "fer", "mer", "ten", "lus", "nus", "syl", "tec", "mex", "pub", "rym", "tuc",
43+ "fyl", "lep", "deb", "ber", "mug", "hut", "tun", "byl", "sud", "pem", "dev", "lur", "def",
44+ "bus", "bep", "run", "mel", "pex", "dyt", "byt", "typ", "lev", "myl", "wed", "duc", "fur",
45+ "fex", "nul", "luc", "len", "ner", "lex", "rup", "ned", "lec", "ryd", "lyd", "fen", "wel",
46+ "nyd", "hus", "rel", "rud", "nes", "hes", "fet", "des", "ret", "dun", "ler", "nyr", "seb",
47+ "hul", "ryl", "lud", "rem", "lys", "fyn", "wer", "ryc", "sug", "nys", "nyl", "lyn", "dyn",
48+ "dem", "lux", "fed", "sed", "bec", "mun", "lyr", "tes", "mud", "nyt", "byr", "sen", "weg",
49+ "fyr", "mur", "tel", "rep", "teg", "pec", "nel", "nev", "fes",
50+];
51+
52+fn prefix_index(s: &str) -> Option<u8> {
53+ PREFIXES.iter().position(|p| *p == s).map(|i| i as u8)
54+}
55+
56+fn suffix_index(s: &str) -> Option<u8> {
57+ SUFFIXES.iter().position(|p| *p == s).map(|i| i as u8)
58+}
59+
60+/// Encode bytes as a hyphen-joined `@p` string.
61+pub fn to_ob(bytes: &[u8]) -> String {
62+ bytes
63+ .chunks(2)
64+ .map(|pair| {
65+ let hi = pair[0] as usize;
66+ // An odd trailing byte (never happens for 32-byte keys) reuses suffix 0.
67+ let lo = pair.get(1).copied().unwrap_or(0) as usize;
68+ format!("{}{}", PREFIXES[hi], SUFFIXES[lo])
69+ })
70+ .collect::<Vec<_>>()
71+ .join("-")
72+}
73+
74+/// Decode a `@p` string back to bytes. Returns `None` if any syllable is invalid.
75+pub fn from_ob(s: &str) -> Option<Vec<u8>> {
76+ let parts: Vec<&str> = s.split('-').filter(|p| !p.is_empty()).collect();
77+ if parts.is_empty() {
78+ return None;
79+ }
80+ let mut out = Vec::with_capacity(parts.len() * 2);
81+ for part in parts {
82+ if part.len() != 6 {
83+ return None;
84+ }
85+ let hi = prefix_index(&part[0..3])?;
86+ let lo = suffix_index(&part[3..6])?;
87+ out.push(hi);
88+ out.push(lo);
89+ }
90+ Some(out)
91+}
92+
93+/// Parse a contact identifier given as either a `@p` string or 64-char hex.
94+/// Returns the lowercase hex of a valid 32-byte Ed25519 public key.
95+pub fn try_parse_pubkey(input: &str) -> Option<String> {
96+ let trimmed = input.trim();
97+ // Try @p first (contains hyphens or non-hex syllables), then hex.
98+ if let Some(bytes) = from_ob(trimmed) {
99+ if bytes.len() == 32 {
100+ return Some(hex::encode(bytes));
101+ }
102+ }
103+ if let Ok(bytes) = hex::decode(trimmed.to_lowercase()) {
104+ if bytes.len() == 32 {
105+ return Some(hex::encode(bytes));
106+ }
107+ }
108+ None
109+}
110+
111+#[cfg(test)]
112+mod tests {
113+ use super::*;
114+
115+ #[test]
116+ fn tables_are_full_and_distinct() {
117+ assert_eq!(PREFIXES.len(), 256);
118+ assert_eq!(SUFFIXES.len(), 256);
119+ assert_eq!(PREFIXES[0], "doz");
120+ assert_eq!(SUFFIXES[0], "zod");
121+ }
122+
123+ #[test]
124+ fn round_trip_32_bytes() {
125+ let key: Vec<u8> = (0u8..32).collect();
126+ let ob = to_ob(&key);
127+ assert_eq!(ob.split('-').count(), 16, "16 syllables for 32 bytes");
128+ assert_eq!(from_ob(&ob), Some(key));
129+ }
130+
131+ #[test]
132+ fn zero_key_is_dozzod_repeated() {
133+ let ob = to_ob(&[0u8; 32]);
134+ assert!(ob.starts_with("dozzod-dozzod"));
135+ }
136+
137+ #[test]
138+ fn try_parse_accepts_hex_and_ob() {
139+ let key = [0xabu8; 32];
140+ let hex_str = hex::encode(key);
141+ let ob = to_ob(&key);
142+ assert_eq!(try_parse_pubkey(&hex_str), Some(hex_str.clone()));
143+ assert_eq!(try_parse_pubkey(&ob), Some(hex_str.clone()));
144+ assert_eq!(try_parse_pubkey(&hex_str.to_uppercase()), Some(hex_str));
145+ }
146+
147+ #[test]
148+ fn try_parse_rejects_wrong_length_and_garbage() {
149+ assert_eq!(try_parse_pubkey("deadbeef"), None);
150+ assert_eq!(try_parse_pubkey("not-a-key"), None);
151+ assert_eq!(try_parse_pubkey(""), None);
152+ }
153+}
core/src/protocol.rsnew file+191
@@ -0,0 +1,191 @@
1+//! Plaintext payload (de)serialization and the wire envelope (PROTOCOL.md §4).
2+//!
3+//! Plaintext payloads are UTF-8 JSON with a `type` discriminator and an int64
4+//! millisecond `ts`. Unknown types are ignored (return `None`).
5+
6+use serde::{Deserialize, Serialize};
7+
8+/// A decrypted plaintext payload. `photo` stays base64 (as on the wire).
9+#[derive(Debug, Clone, PartialEq, Eq)]
10+pub enum Payload {
11+ Text {
12+ id: String,
13+ body: String,
14+ },
15+ DeliveryAck {
16+ ack_ids: Vec<String>,
17+ },
18+ Profile {
19+ display_name: String,
20+ bio: String,
21+ photo: Option<String>,
22+ },
23+}
24+
25+/// A parsed payload plus its sender-supplied timestamp (ms).
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub struct ParsedPayload {
28+ pub ts: i64,
29+ pub payload: Payload,
30+}
31+
32+/// The server-visible envelope: only the recipient and the opaque blob.
33+#[derive(Debug, Clone, Serialize, Deserialize)]
34+pub struct Envelope {
35+ pub to: String,
36+ #[serde(rename = "encryptedBlob")]
37+ pub encrypted_blob: String,
38+}
39+
40+/// Serialize a payload to the plaintext JSON sent inside the AEAD ciphertext.
41+pub fn serialize_payload(payload: &Payload, ts: i64) -> String {
42+ let value = match payload {
43+ Payload::Text { id, body } => serde_json::json!({
44+ "type": "text",
45+ "id": id,
46+ "body": body,
47+ "ts": ts,
48+ }),
49+ Payload::DeliveryAck { ack_ids } => serde_json::json!({
50+ "type": "delivery.ack",
51+ "ack_ids": ack_ids,
52+ "ts": ts,
53+ }),
54+ Payload::Profile {
55+ display_name,
56+ bio,
57+ photo,
58+ } => serde_json::json!({
59+ "type": "profile",
60+ "display_name": display_name,
61+ "bio": bio,
62+ "photo": photo,
63+ "ts": ts,
64+ }),
65+ };
66+ value.to_string()
67+}
68+
69+/// Parse a plaintext payload. Returns `None` for unknown/malformed types so the
70+/// caller silently ignores them (forward compatibility, PROTOCOL.md §4).
71+pub fn parse_payload(bytes: &[u8]) -> Option<ParsedPayload> {
72+ let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
73+ let ts = v.get("ts").and_then(|t| t.as_i64()).unwrap_or(0);
74+ let ty = v.get("type")?.as_str()?;
75+ let payload = match ty {
76+ "text" => {
77+ let id = v.get("id")?.as_str()?.to_string();
78+ let body = v.get("body")?.as_str()?.to_string();
79+ Payload::Text { id, body }
80+ }
81+ "delivery.ack" => {
82+ let ack_ids = v
83+ .get("ack_ids")?
84+ .as_array()?
85+ .iter()
86+ .filter_map(|x| x.as_str().map(str::to_string))
87+ .collect();
88+ Payload::DeliveryAck { ack_ids }
89+ }
90+ "profile" => {
91+ let display_name = v
92+ .get("display_name")
93+ .and_then(|x| x.as_str())
94+ .unwrap_or("")
95+ .to_string();
96+ let bio = v
97+ .get("bio")
98+ .and_then(|x| x.as_str())
99+ .unwrap_or("")
100+ .to_string();
101+ let photo = v
102+ .get("photo")
103+ .and_then(|x| x.as_str())
104+ .map(str::to_string);
105+ Payload::Profile {
106+ display_name,
107+ bio,
108+ photo,
109+ }
110+ }
111+ _ => return None,
112+ };
113+ Some(ParsedPayload { ts, payload })
114+}
115+
116+#[cfg(test)]
117+mod tests {
118+ use super::*;
119+
120+ fn reparse(p: &Payload, ts: i64) -> ParsedPayload {
121+ let json = serialize_payload(p, ts);
122+ parse_payload(json.as_bytes()).unwrap()
123+ }
124+
125+ #[test]
126+ fn text_round_trip() {
127+ let p = Payload::Text {
128+ id: "id-1".into(),
129+ body: "hello".into(),
130+ };
131+ let out = reparse(&p, 1700000000000);
132+ assert_eq!(out.ts, 1700000000000);
133+ assert_eq!(out.payload, p);
134+ }
135+
136+ #[test]
137+ fn ack_round_trip() {
138+ let p = Payload::DeliveryAck {
139+ ack_ids: vec!["a".into(), "b".into()],
140+ };
141+ assert_eq!(reparse(&p, 42).payload, p);
142+ }
143+
144+ #[test]
145+ fn profile_round_trip_with_and_without_photo() {
146+ let with = Payload::Profile {
147+ display_name: "Alice".into(),
148+ bio: "writing".into(),
149+ photo: Some("aGVsbG8=".into()),
150+ };
151+ assert_eq!(reparse(&with, 1).payload, with);
152+ let without = Payload::Profile {
153+ display_name: "Bob".into(),
154+ bio: "".into(),
155+ photo: None,
156+ };
157+ assert_eq!(reparse(&without, 1).payload, without);
158+ }
159+
160+ #[test]
161+ fn unknown_type_is_ignored() {
162+ let json = r#"{"type":"read.receipt","ts":1}"#;
163+ assert!(parse_payload(json.as_bytes()).is_none());
164+ }
165+
166+ #[test]
167+ fn text_missing_field_is_rejected() {
168+ let json = r#"{"type":"text","id":"x","ts":1}"#;
169+ assert!(parse_payload(json.as_bytes()).is_none());
170+ }
171+
172+ #[test]
173+ fn missing_ts_defaults_to_zero() {
174+ let json = r#"{"type":"text","id":"x","body":"y"}"#;
175+ assert_eq!(parse_payload(json.as_bytes()).unwrap().ts, 0);
176+ }
177+
178+ #[test]
179+ fn snake_case_field_names_on_wire() {
180+ let p = Payload::Profile {
181+ display_name: "A".into(),
182+ bio: "B".into(),
183+ photo: None,
184+ };
185+ let json = serialize_payload(&p, 1);
186+ assert!(json.contains("\"display_name\""));
187+ let ack = serialize_payload(&Payload::DeliveryAck { ack_ids: vec![] }, 1);
188+ assert!(ack.contains("\"ack_ids\""));
189+ assert!(ack.contains("\"delivery.ack\""));
190+ }
191+}