An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs

+5046 -55
+226 -13
apps/identity-wallet/pnpm-lock.yaml
··· 11 11 '@tauri-apps/api': 12 12 specifier: ^2 13 13 version: 2.10.1 14 + qrcode: 15 + specifier: ^1.5.4 16 + version: 1.5.4 14 17 devDependencies: 15 18 '@sveltejs/adapter-static': 16 19 specifier: ^3.0.8 ··· 24 27 '@types/node': 25 28 specifier: ^22 26 29 version: 22.19.15 30 + '@types/qrcode': 31 + specifier: ^1.5.5 32 + version: 1.5.6 27 33 svelte: 28 34 specifier: ^5.25.8 29 35 version: 5.53.12 ··· 251 257 resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} 252 258 cpu: [arm] 253 259 os: [linux] 254 - libc: [glibc] 255 260 256 261 '@rollup/rollup-linux-arm-musleabihf@4.59.0': 257 262 resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} 258 263 cpu: [arm] 259 264 os: [linux] 260 - libc: [musl] 261 265 262 266 '@rollup/rollup-linux-arm64-gnu@4.59.0': 263 267 resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} 264 268 cpu: [arm64] 265 269 os: [linux] 266 - libc: [glibc] 267 270 268 271 '@rollup/rollup-linux-arm64-musl@4.59.0': 269 272 resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} 270 273 cpu: [arm64] 271 274 os: [linux] 272 - libc: [musl] 273 275 274 276 '@rollup/rollup-linux-loong64-gnu@4.59.0': 275 277 resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} 276 278 cpu: [loong64] 277 279 os: [linux] 278 - libc: [glibc] 279 280 280 281 '@rollup/rollup-linux-loong64-musl@4.59.0': 281 282 resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} 282 283 cpu: [loong64] 283 284 os: [linux] 284 - libc: [musl] 285 285 286 286 '@rollup/rollup-linux-ppc64-gnu@4.59.0': 287 287 resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} 288 288 cpu: [ppc64] 289 289 os: [linux] 290 - libc: [glibc] 291 290 292 291 '@rollup/rollup-linux-ppc64-musl@4.59.0': 293 292 resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} 294 293 cpu: [ppc64] 295 294 os: [linux] 296 - libc: [musl] 297 295 298 296 '@rollup/rollup-linux-riscv64-gnu@4.59.0': 299 297 resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} 300 298 cpu: [riscv64] 301 299 os: [linux] 302 - libc: [glibc] 303 300 304 301 '@rollup/rollup-linux-riscv64-musl@4.59.0': 305 302 resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} 306 303 cpu: [riscv64] 307 304 os: [linux] 308 - libc: [musl] 309 305 310 306 '@rollup/rollup-linux-s390x-gnu@4.59.0': 311 307 resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} 312 308 cpu: [s390x] 313 309 os: [linux] 314 - libc: [glibc] 315 310 316 311 '@rollup/rollup-linux-x64-gnu@4.59.0': 317 312 resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} 318 313 cpu: [x64] 319 314 os: [linux] 320 - libc: [glibc] 321 315 322 316 '@rollup/rollup-linux-x64-musl@4.59.0': 323 317 resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} 324 318 cpu: [x64] 325 319 os: [linux] 326 - libc: [musl] 327 320 328 321 '@rollup/rollup-openbsd-x64@4.59.0': 329 322 resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} ··· 411 404 '@types/node@22.19.15': 412 405 resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} 413 406 407 + '@types/qrcode@1.5.6': 408 + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} 409 + 414 410 '@types/trusted-types@2.0.7': 415 411 resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} 416 412 ··· 422 418 resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} 423 419 engines: {node: '>=0.4.0'} 424 420 hasBin: true 421 + 422 + ansi-regex@5.0.1: 423 + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 424 + engines: {node: '>=8'} 425 + 426 + ansi-styles@4.3.0: 427 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 428 + engines: {node: '>=8'} 425 429 426 430 aria-query@5.3.1: 427 431 resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} ··· 431 435 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 432 436 engines: {node: '>= 0.4'} 433 437 438 + camelcase@5.3.1: 439 + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} 440 + engines: {node: '>=6'} 441 + 434 442 chokidar@4.0.3: 435 443 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 436 444 engines: {node: '>= 14.16.0'} 437 445 446 + cliui@6.0.0: 447 + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} 448 + 438 449 clsx@2.1.1: 439 450 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 440 451 engines: {node: '>=6'} 441 452 453 + color-convert@2.0.1: 454 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 455 + engines: {node: '>=7.0.0'} 456 + 457 + color-name@1.1.4: 458 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 459 + 442 460 cookie@0.6.0: 443 461 resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 444 462 engines: {node: '>= 0.6'} ··· 452 470 supports-color: 453 471 optional: true 454 472 473 + decamelize@1.2.0: 474 + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} 475 + engines: {node: '>=0.10.0'} 476 + 455 477 deepmerge@4.3.1: 456 478 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 457 479 engines: {node: '>=0.10.0'} ··· 459 481 devalue@5.6.4: 460 482 resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} 461 483 484 + dijkstrajs@1.0.3: 485 + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} 486 + 487 + emoji-regex@8.0.0: 488 + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 489 + 462 490 esbuild@0.25.12: 463 491 resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} 464 492 engines: {node: '>=18'} ··· 479 507 picomatch: 480 508 optional: true 481 509 510 + find-up@4.1.0: 511 + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} 512 + engines: {node: '>=8'} 513 + 482 514 fsevents@2.3.3: 483 515 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 484 516 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 485 517 os: [darwin] 486 518 519 + get-caller-file@2.0.5: 520 + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} 521 + engines: {node: 6.* || 8.* || >= 10.*} 522 + 523 + is-fullwidth-code-point@3.0.0: 524 + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 525 + engines: {node: '>=8'} 526 + 487 527 is-reference@3.0.3: 488 528 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 489 529 ··· 494 534 locate-character@3.0.0: 495 535 resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} 496 536 537 + locate-path@5.0.0: 538 + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} 539 + engines: {node: '>=8'} 540 + 497 541 magic-string@0.30.21: 498 542 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 499 543 ··· 513 557 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 514 558 hasBin: true 515 559 560 + p-limit@2.3.0: 561 + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} 562 + engines: {node: '>=6'} 563 + 564 + p-locate@4.1.0: 565 + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} 566 + engines: {node: '>=8'} 567 + 568 + p-try@2.2.0: 569 + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} 570 + engines: {node: '>=6'} 571 + 572 + path-exists@4.0.0: 573 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 574 + engines: {node: '>=8'} 575 + 516 576 picocolors@1.1.1: 517 577 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 518 578 519 579 picomatch@4.0.3: 520 580 resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} 521 581 engines: {node: '>=12'} 582 + 583 + pngjs@5.0.0: 584 + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} 585 + engines: {node: '>=10.13.0'} 522 586 523 587 postcss@8.5.8: 524 588 resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} 525 589 engines: {node: ^10 || ^12 || >=14} 526 590 591 + qrcode@1.5.4: 592 + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} 593 + engines: {node: '>=10.13.0'} 594 + hasBin: true 595 + 527 596 readdirp@4.1.2: 528 597 resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} 529 598 engines: {node: '>= 14.18.0'} 599 + 600 + require-directory@2.1.1: 601 + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} 602 + engines: {node: '>=0.10.0'} 603 + 604 + require-main-filename@2.0.0: 605 + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} 530 606 531 607 rollup@4.59.0: 532 608 resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} ··· 537 613 resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 538 614 engines: {node: '>=6'} 539 615 616 + set-blocking@2.0.0: 617 + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} 618 + 540 619 set-cookie-parser@3.0.1: 541 620 resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} 542 621 ··· 547 626 source-map-js@1.2.1: 548 627 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 549 628 engines: {node: '>=0.10.0'} 629 + 630 + string-width@4.2.3: 631 + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 632 + engines: {node: '>=8'} 633 + 634 + strip-ansi@6.0.1: 635 + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 636 + engines: {node: '>=8'} 550 637 551 638 svelte-check@4.4.5: 552 639 resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==} ··· 627 714 vite: 628 715 optional: true 629 716 717 + which-module@2.0.1: 718 + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} 719 + 720 + wrap-ansi@6.2.0: 721 + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} 722 + engines: {node: '>=8'} 723 + 724 + y18n@4.0.3: 725 + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} 726 + 727 + yargs-parser@18.1.3: 728 + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} 729 + engines: {node: '>=6'} 730 + 731 + yargs@15.4.1: 732 + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} 733 + engines: {node: '>=8'} 734 + 630 735 zimmerframe@1.1.4: 631 736 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 632 737 ··· 868 973 dependencies: 869 974 undici-types: 6.21.0 870 975 976 + '@types/qrcode@1.5.6': 977 + dependencies: 978 + '@types/node': 22.19.15 979 + 871 980 '@types/trusted-types@2.0.7': {} 872 981 873 982 '@typescript-eslint/types@8.57.0': {} 874 983 875 984 acorn@8.16.0: {} 876 985 986 + ansi-regex@5.0.1: {} 987 + 988 + ansi-styles@4.3.0: 989 + dependencies: 990 + color-convert: 2.0.1 991 + 877 992 aria-query@5.3.1: {} 878 993 879 994 axobject-query@4.1.0: {} 880 995 996 + camelcase@5.3.1: {} 997 + 881 998 chokidar@4.0.3: 882 999 dependencies: 883 1000 readdirp: 4.1.2 884 1001 1002 + cliui@6.0.0: 1003 + dependencies: 1004 + string-width: 4.2.3 1005 + strip-ansi: 6.0.1 1006 + wrap-ansi: 6.2.0 1007 + 885 1008 clsx@2.1.1: {} 886 1009 1010 + color-convert@2.0.1: 1011 + dependencies: 1012 + color-name: 1.1.4 1013 + 1014 + color-name@1.1.4: {} 1015 + 887 1016 cookie@0.6.0: {} 888 1017 889 1018 debug@4.4.3: 890 1019 dependencies: 891 1020 ms: 2.1.3 892 1021 1022 + decamelize@1.2.0: {} 1023 + 893 1024 deepmerge@4.3.1: {} 894 1025 895 1026 devalue@5.6.4: {} 1027 + 1028 + dijkstrajs@1.0.3: {} 1029 + 1030 + emoji-regex@8.0.0: {} 896 1031 897 1032 esbuild@0.25.12: 898 1033 optionalDependencies: ··· 934 1069 optionalDependencies: 935 1070 picomatch: 4.0.3 936 1071 1072 + find-up@4.1.0: 1073 + dependencies: 1074 + locate-path: 5.0.0 1075 + path-exists: 4.0.0 1076 + 937 1077 fsevents@2.3.3: 938 1078 optional: true 939 1079 1080 + get-caller-file@2.0.5: {} 1081 + 1082 + is-fullwidth-code-point@3.0.0: {} 1083 + 940 1084 is-reference@3.0.3: 941 1085 dependencies: 942 1086 '@types/estree': 1.0.8 ··· 944 1088 kleur@4.1.5: {} 945 1089 946 1090 locate-character@3.0.0: {} 1091 + 1092 + locate-path@5.0.0: 1093 + dependencies: 1094 + p-locate: 4.1.0 947 1095 948 1096 magic-string@0.30.21: 949 1097 dependencies: ··· 957 1105 958 1106 nanoid@3.3.11: {} 959 1107 1108 + p-limit@2.3.0: 1109 + dependencies: 1110 + p-try: 2.2.0 1111 + 1112 + p-locate@4.1.0: 1113 + dependencies: 1114 + p-limit: 2.3.0 1115 + 1116 + p-try@2.2.0: {} 1117 + 1118 + path-exists@4.0.0: {} 1119 + 960 1120 picocolors@1.1.1: {} 961 1121 962 1122 picomatch@4.0.3: {} 1123 + 1124 + pngjs@5.0.0: {} 963 1125 964 1126 postcss@8.5.8: 965 1127 dependencies: ··· 967 1129 picocolors: 1.1.1 968 1130 source-map-js: 1.2.1 969 1131 1132 + qrcode@1.5.4: 1133 + dependencies: 1134 + dijkstrajs: 1.0.3 1135 + pngjs: 5.0.0 1136 + yargs: 15.4.1 1137 + 970 1138 readdirp@4.1.2: {} 971 1139 1140 + require-directory@2.1.1: {} 1141 + 1142 + require-main-filename@2.0.0: {} 1143 + 972 1144 rollup@4.59.0: 973 1145 dependencies: 974 1146 '@types/estree': 1.0.8 ··· 1004 1176 dependencies: 1005 1177 mri: 1.2.0 1006 1178 1179 + set-blocking@2.0.0: {} 1180 + 1007 1181 set-cookie-parser@3.0.1: {} 1008 1182 1009 1183 sirv@3.0.2: ··· 1013 1187 totalist: 3.0.1 1014 1188 1015 1189 source-map-js@1.2.1: {} 1190 + 1191 + string-width@4.2.3: 1192 + dependencies: 1193 + emoji-regex: 8.0.0 1194 + is-fullwidth-code-point: 3.0.0 1195 + strip-ansi: 6.0.1 1196 + 1197 + strip-ansi@6.0.1: 1198 + dependencies: 1199 + ansi-regex: 5.0.1 1016 1200 1017 1201 svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.12)(typescript@5.9.3): 1018 1202 dependencies: ··· 1073 1257 vitefu@1.1.2(vite@6.4.1(@types/node@22.19.15)): 1074 1258 optionalDependencies: 1075 1259 vite: 6.4.1(@types/node@22.19.15) 1260 + 1261 + which-module@2.0.1: {} 1262 + 1263 + wrap-ansi@6.2.0: 1264 + dependencies: 1265 + ansi-styles: 4.3.0 1266 + string-width: 4.2.3 1267 + strip-ansi: 6.0.1 1268 + 1269 + y18n@4.0.3: {} 1270 + 1271 + yargs-parser@18.1.3: 1272 + dependencies: 1273 + camelcase: 5.3.1 1274 + decamelize: 1.2.0 1275 + 1276 + yargs@15.4.1: 1277 + dependencies: 1278 + cliui: 6.0.0 1279 + decamelize: 1.2.0 1280 + find-up: 4.1.0 1281 + get-caller-file: 2.0.5 1282 + require-directory: 2.1.1 1283 + require-main-filename: 2.0.0 1284 + set-blocking: 2.0.0 1285 + string-width: 4.2.3 1286 + which-module: 2.0.1 1287 + y18n: 4.0.3 1288 + yargs-parser: 18.1.3 1076 1289 1077 1290 zimmerframe@1.1.4: {}
+30 -1
apps/identity-wallet/src/lib/components/onboarding/DIDSuccessScreen.svelte
··· 14 14 ? `did:plc:${did.slice(8, 13)}…${did.slice(-4)}` 15 15 : did 16 16 ); 17 + 18 + let copied = $state(false); 19 + 20 + async function copyDid() { 21 + try { 22 + await navigator.clipboard.writeText(did); 23 + copied = true; 24 + setTimeout(() => { copied = false; }, 2000); 25 + } catch (e) { 26 + console.error('clipboard write failed:', e); 27 + } 28 + } 17 29 </script> 18 30 19 31 <div class="screen"> 20 32 <div class="success-icon" aria-hidden="true">✓</div> 21 33 <h2>Identity Created!</h2> 22 34 <p class="label">Your decentralized identifier</p> 23 - <code class="did">{displayDid}</code> 35 + <button class="did" onclick={copyDid} title="Tap to copy full DID"> 36 + {displayDid} 37 + <span class="copy-hint">{copied ? 'Copied!' : 'Tap to copy'}</span> 38 + </button> 24 39 <button class="cta" onclick={oncontinue}>Continue</button> 25 40 </div> 26 41 ··· 68 83 padding: 0.5rem 1rem; 69 84 border-radius: 8px; 70 85 word-break: break-all; 86 + border: none; 87 + cursor: pointer; 88 + display: flex; 89 + flex-direction: column; 90 + align-items: center; 91 + gap: 0.25rem; 92 + width: 100%; 93 + max-width: 320px; 94 + } 95 + 96 + .copy-hint { 97 + font-family: system-ui, sans-serif; 98 + font-size: 0.75rem; 99 + color: #6b7280; 71 100 } 72 101 73 102 .cta {
+263
docs/design-plans/2026-03-15-MM-144.md
··· 1 + # MM-144: Mobile Onboarding Flow UI Design 2 + 3 + ## Summary 4 + 5 + This document specifies the onboarding flow for `apps/identity-wallet/`, the Tauri v2 iOS app. A new user sees a five-step wizard — Welcome, Claim Code, Email, Handle, Loading — that collects credentials, calls the relay's account-creation endpoint, stores the returned tokens in the iOS Keychain, and hands off to the DID ceremony flow. 6 + 7 + All relay API calls are proxied through Tauri IPC: the Svelte frontend invokes a Rust command, the Rust backend performs the HTTP request, stores tokens, and returns a typed result. The webview never holds raw credentials. 8 + 9 + The work is structured in four phases: first, adding Rust dependencies and implementing the Keychain abstraction; second, implementing the `create_account` IPC command end-to-end; third, building the five Svelte screen components; fourth, wiring the state machine in `+page.svelte` and adding the typed IPC wrapper to `ipc.ts`. 10 + 11 + ## Definition of Done 12 + 13 + 1. A five-screen onboarding wizard (Welcome → Claim Code → Email → Handle → Loading) renders in the app. 14 + 2. Submitting valid credentials calls `POST /v1/accounts/mobile` through Tauri IPC → Rust → HTTP. 15 + 3. `device_token`, `session_token`, and the device private key are stored in the iOS Keychain. 16 + 4. Errors (expired code, redeemed code, email taken, handle taken, network error) display user-friendly messages and return to the appropriate entry screen. 17 + 5. On success, the app navigates to the DID ceremony flow (`nextStep: "did_creation"`). 18 + 6. The flow works in the iOS simulator via `cargo tauri ios dev`. 19 + 20 + ## Acceptance Criteria 21 + 22 + ### MM-144.AC1: Onboarding screens render correctly 23 + - **MM-144.AC1.1 Success:** Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step 24 + - **MM-144.AC1.2 Success:** Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered 25 + - **MM-144.AC1.3 Success:** Email screen shows an email input; the Next button is disabled until a valid email format is entered 26 + - **MM-144.AC1.4 Success:** Handle screen shows a handle input; the Next button is disabled until the handle is non-empty 27 + - **MM-144.AC1.5 Success:** Loading screen shows a spinner and status message while account creation is in progress 28 + - **MM-144.AC1.6 Success:** Each screen's Next/Submit button only advances when its validation condition is met 29 + 30 + ### MM-144.AC2: Account creation succeeds end-to-end 31 + - **MM-144.AC2.1 Success:** Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC 32 + - **MM-144.AC2.2 Success:** The Rust command POSTs to `POST /v1/accounts/mobile` with `email`, `handle`, `claimCode`, `devicePublicKey`, and `platform: "ios"` 33 + - **MM-144.AC2.3 Success:** On 201 response, `device_token` and `session_token` are stored in the iOS Keychain 34 + - **MM-144.AC2.4 Success:** The device P-256 private key is stored in the iOS Keychain before the HTTP request 35 + - **MM-144.AC2.5 Success:** On success, the frontend receives `{ nextStep: "did_creation" }` and advances past the loading screen 36 + 37 + ### MM-144.AC3: Error handling 38 + - **MM-144.AC3.1 Failure:** A relay 404 response (expired claim code) surfaces as "This claim code has expired. Please request a new one." and returns the user to the Claim Code screen 39 + - **MM-144.AC3.2 Failure:** A relay 409 response for a redeemed claim code surfaces as "This claim code has already been used." and returns the user to the Claim Code screen 40 + - **MM-144.AC3.3 Failure:** A relay 409 response for a taken email surfaces as "An account with that email already exists." and returns the user to the Email screen 41 + - **MM-144.AC3.4 Failure:** A relay 409 response for a taken handle surfaces as "That handle is taken. Please choose another." and returns the user to the Handle screen 42 + - **MM-144.AC3.5 Failure:** A network or server error (non-4xx) surfaces as "Couldn't reach the server. Check your connection." and returns the user to the Handle screen 43 + 44 + ### MM-144.AC4: iOS Keychain storage 45 + - **MM-144.AC4.1 Success:** `device_token` is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"device-token"` using `kSecClassGenericPassword` 46 + - **MM-144.AC4.2 Success:** `session_token` is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"session-token"` using `kSecClassGenericPassword` 47 + - **MM-144.AC4.3 Success:** Device P-256 private key (raw bytes) is stored in the iOS Keychain under service `"ezpds-identity-wallet"`, account `"device-private-key"` using `kSecClassGenericPassword` 48 + 49 + ### MM-144.AC5: iOS simulator compatibility 50 + - **MM-144.AC5.1 Success:** `cargo build --workspace` succeeds after adding new Rust dependencies 51 + - **MM-144.AC5.2 Success:** `pnpm build` in `apps/identity-wallet/` succeeds after adding new frontend components 52 + 53 + ## Glossary 54 + 55 + - **Onboarding flow**: The sequence of screens a new user sees before their account exists. Terminates by calling the relay provisioning endpoint and storing credentials. 56 + - **Claim code**: A 6-character alphanumeric code pre-generated by an admin. Redeemable once; identifies a provisioning slot. 57 + - **Handle**: An ATProto handle (e.g. `alice.ezpds.com`). Must be unique across the relay. User-chosen during onboarding. 58 + - **Device keypair**: A P-256 elliptic curve keypair generated on-device at account creation time. The public key is sent to the relay; the private key never leaves the device (stored in Keychain). 59 + - **device_token**: A 32-byte base64url-encoded token returned by the relay, scoped to this device. Used to authenticate device-level operations. 60 + - **session_token**: A 32-byte base64url-encoded token returned by the relay, scoped to this session. Used to authenticate user-level API calls. 61 + - **iOS Keychain**: Apple's secure credential store. Items are AES-256-GCM encrypted, hardware-backed, and scoped to the app's bundle ID. Accessed via Security.framework. 62 + - **`kSecClassGenericPassword`**: The Keychain item class for storing arbitrary secrets (tokens, keys). Identified by `service` + `account` pair. 63 + - **`security-framework`**: A Rust crate that wraps Apple's Security.framework, providing safe bindings to Keychain operations. 64 + - **`reqwest`**: An async Rust HTTP client library. Used with `rustls-tls` (no OpenSSL dependency) for iOS compatibility. 65 + - **State machine**: The frontend navigation model. A `step` variable of type `OnboardingStep` determines which screen component is rendered. Transitions are triggered by user actions and async results. 66 + - **DID ceremony**: The next phase after onboarding (out of scope for this ticket). The onboarding flow hands off to it by emitting `nextStep: "did_creation"`. 67 + 68 + ## Architecture 69 + 70 + Five-screen single-page state machine in `+page.svelte`. All Svelte screens are separate components in `src/lib/components/onboarding/`. The `create_account` Tauri IPC command is the only new Rust command — it encapsulates key generation, HTTP, and Keychain writes atomically from the frontend's perspective. 71 + 72 + ### Frontend Structure 73 + 74 + ``` 75 + src/ 76 + routes/ 77 + +page.svelte ← owns step + form $state; renders active screen 78 + lib/ 79 + ipc.ts ← adds createAccount() alongside existing greet() 80 + components/ 81 + onboarding/ 82 + WelcomeScreen.svelte 83 + ClaimCodeScreen.svelte ← 6-char, auto-uppercase, length-gated submit 84 + EmailScreen.svelte ← regex email validation 85 + HandleScreen.svelte ← non-empty validation 86 + LoadingScreen.svelte ← spinner + status text 87 + ``` 88 + 89 + ### State Machine 90 + 91 + ```typescript 92 + // Contracts only — implementation in Phase 4 93 + type OnboardingStep = 'welcome' | 'claim_code' | 'email' | 'handle' | 'loading' | 'error'; 94 + 95 + // +page.svelte state: 96 + let step: OnboardingStep = $state('welcome'); 97 + let form = $state({ claimCode: '', email: '', handle: '' }); 98 + let errorMessage: string | null = $state(null); 99 + ``` 100 + 101 + ### IPC Contract 102 + 103 + ```typescript 104 + // src/lib/ipc.ts additions 105 + type CreateAccountParams = { claimCode: string; email: string; handle: string }; 106 + type CreateAccountResult = { nextStep: string }; 107 + 108 + export const createAccount: (p: CreateAccountParams) => Promise<CreateAccountResult>; 109 + ``` 110 + 111 + ```rust 112 + // src-tauri/src/lib.rs — command signature 113 + #[tauri::command] 114 + async fn create_account( 115 + claim_code: String, 116 + email: String, 117 + handle: String, 118 + ) -> Result<CreateAccountResult, CreateAccountError> 119 + ``` 120 + 121 + ### Rust Backend Structure 122 + 123 + ``` 124 + src-tauri/src/ 125 + lib.rs ← registers create_account alongside greet 126 + keychain.rs ← store_token / get_token via security-framework 127 + http.rs ← relay HTTP client wrapper (reqwest) 128 + ``` 129 + 130 + ### Error Mapping 131 + 132 + ```rust 133 + // Contracts only 134 + #[derive(serde::Serialize)] 135 + #[serde(tag = "code")] 136 + enum CreateAccountError { 137 + ExpiredCode, 138 + RedeemedCode, 139 + EmailTaken, 140 + HandleTaken, 141 + NetworkError { message: String }, 142 + Unknown { message: String }, 143 + } 144 + ``` 145 + 146 + HTTP status → error variant: 147 + - 404 → `ExpiredCode` 148 + - 409 + body "claim code already redeemed" → `RedeemedCode` 149 + - 409 + body "email already taken" → `EmailTaken` 150 + - 409 + body "handle already taken" → `HandleTaken` 151 + - Other / network failure → `NetworkError` 152 + 153 + ### Relay Configuration 154 + 155 + ```rust 156 + // src-tauri/src/http.rs 157 + #[cfg(debug_assertions)] 158 + const RELAY_BASE_URL: &str = "http://localhost:8080"; 159 + #[cfg(not(debug_assertions))] 160 + const RELAY_BASE_URL: &str = "https://relay.ezpds.com"; 161 + ``` 162 + 163 + ### New Cargo Dependencies 164 + 165 + ```toml 166 + # src-tauri/Cargo.toml 167 + reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } 168 + security-framework = "3" 169 + ``` 170 + 171 + ## Existing Patterns 172 + 173 + **IPC commands:** Follow the existing `greet` pattern — `#[tauri::command]`, registered with `generate_handler!` in `run()`, wrapped in `ipc.ts`. `create_account` is a new command alongside `greet`. 174 + 175 + **`ipc.ts`:** All IPC calls go through this file; no raw `invoke()` in components. `createAccount()` follows the same pattern as `greet()`. 176 + 177 + **Svelte 5 runes:** `$state()` for reactive variables, consistent with `+page.svelte` existing usage. 178 + 179 + **Cargo workspace:** New dependencies declared locally in `src-tauri/Cargo.toml`, not in `[workspace.dependencies]` — consistent with all Tauri-specific deps being local. 180 + 181 + ## Implementation Phases 182 + 183 + <!-- START_PHASE_1 --> 184 + ### Phase 1: Rust Infrastructure — Keychain + HTTP Client 185 + 186 + **Goal:** Add `reqwest` and `security-framework` to `src-tauri/Cargo.toml`, implement `keychain.rs` (store/get tokens via iOS Keychain), and implement `http.rs` (relay HTTP client wrapper). No new IPC command yet — this phase is pure infrastructure. 187 + 188 + **Components:** 189 + - `apps/identity-wallet/src-tauri/Cargo.toml` — add `reqwest` (0.12, json + rustls-tls) and `security-framework` (3) dependencies; add `tokio` if not present for async runtime 190 + - `apps/identity-wallet/src-tauri/src/keychain.rs` — `store_token(service, account, data) -> Result<()>` and `get_token(service, account) -> Result<Vec<u8>>` using `security-framework` 191 + - `apps/identity-wallet/src-tauri/src/http.rs` — `RelayClient` struct with `base_url`, `new() -> Self`, and `post_json<T, R>(path, body) -> Result<R>` using `reqwest`; `RELAY_BASE_URL` constant with `#[cfg(debug_assertions)]` split 192 + - `apps/identity-wallet/src-tauri/src/lib.rs` — add `mod keychain; mod http;` declarations 193 + 194 + **Dependencies:** None (Phase 1 is infrastructure; no IPC command implemented here). 195 + 196 + **Done when:** `cargo build --workspace` succeeds cleanly. `cargo clippy --workspace -- -D warnings` passes. `cargo fmt --all --check` passes. 197 + <!-- END_PHASE_1 --> 198 + 199 + <!-- START_PHASE_2 --> 200 + ### Phase 2: create_account IPC Command 201 + 202 + **Goal:** Implement the `create_account` Tauri IPC command. It generates a P-256 keypair using `crates/crypto`, stores the private key in Keychain, POSTs to the relay, stores `device_token` and `session_token` in Keychain, and returns `CreateAccountResult` or a typed `CreateAccountError`. 203 + 204 + **Components:** 205 + - `apps/identity-wallet/src-tauri/Cargo.toml` — add `ezpds-crypto` workspace dependency (path: `../../../crates/crypto`) 206 + - `apps/identity-wallet/src-tauri/src/lib.rs` — implement `create_account` command: gen keypair via `crypto`, store privkey in Keychain, call `http::RelayClient::post_json`, store tokens in Keychain, return result; add `CreateAccountResult` and `CreateAccountError` types; register command in `generate_handler!` 207 + - `apps/identity-wallet/src-tauri/src/keychain.rs` — may need `store_bytes` variant alongside `store_token` for raw key material 208 + 209 + **Dependencies:** Phase 1 (keychain.rs and http.rs must exist). 210 + 211 + **Done when:** `cargo build --workspace` succeeds. The command compiles and is registered. `cargo clippy` and `cargo fmt --check` pass. (End-to-end HTTP test requires simulator and running relay — manual only.) 212 + <!-- END_PHASE_2 --> 213 + 214 + <!-- START_PHASE_3 --> 215 + ### Phase 3: Onboarding Screen Components 216 + 217 + **Goal:** Build the five Svelte screen components. Each is self-contained: it owns local validation state and emits events to the parent. No global state or IPC calls in the components themselves. 218 + 219 + **Components:** 220 + - `apps/identity-wallet/src/lib/components/onboarding/WelcomeScreen.svelte` — app name/tagline, "Get Started" button, emits `start` event 221 + - `apps/identity-wallet/src/lib/components/onboarding/ClaimCodeScreen.svelte` — 6-char alphanumeric input (auto-uppercase), Next disabled until `value.length === 6`, emits `next` with value 222 + - `apps/identity-wallet/src/lib/components/onboarding/EmailScreen.svelte` — email input, basic regex validation, Next disabled until valid, emits `next` with value 223 + - `apps/identity-wallet/src/lib/components/onboarding/HandleScreen.svelte` — handle input, non-empty validation, Next disabled until non-empty, emits `next` with value 224 + - `apps/identity-wallet/src/lib/components/onboarding/LoadingScreen.svelte` — spinner, status text prop, no interactive elements 225 + 226 + **Dependencies:** None (components are standalone; no IPC yet). 227 + 228 + **Done when:** `pnpm build` in `apps/identity-wallet/` succeeds with no TypeScript errors. Components render without error (manual visual check in simulator or browser dev). 229 + <!-- END_PHASE_3 --> 230 + 231 + <!-- START_PHASE_4 --> 232 + ### Phase 4: State Machine Orchestrator + ipc.ts Integration 233 + 234 + **Goal:** Wire up `+page.svelte` as the onboarding state machine. Replace the `greet` demo with the five-screen wizard. Add `createAccount()` to `ipc.ts`. Handle success (navigate to DID ceremony placeholder) and all error cases (typed error → user message → step reversion). 235 + 236 + **Components:** 237 + - `apps/identity-wallet/src/lib/ipc.ts` — add `CreateAccountParams`, `CreateAccountResult`, `CreateAccountError` types and `createAccount()` wrapper 238 + - `apps/identity-wallet/src/routes/+page.svelte` — replace greet demo with onboarding state machine: `step: OnboardingStep` + `form` + `errorMessage` using Svelte 5 `$state`; render active screen component; on loading step invoke `createAccount()`, on success advance to DID placeholder, on error map code → message + step reversion 239 + 240 + **Error → screen mapping:** 241 + - `EXPIRED_CODE` / `REDEEMED_CODE` → `'claim_code'` step + message 242 + - `EMAIL_TAKEN` → `'email'` step + message 243 + - `HANDLE_TAKEN` → `'handle'` step + message 244 + - `NETWORK_ERROR` / `UNKNOWN` → `'handle'` step + message 245 + 246 + **Dependencies:** Phase 2 (IPC command), Phase 3 (screen components). 247 + 248 + **Done when:** `pnpm build` succeeds. State machine renders each screen in sequence. Error cases display correct messages and revert to correct step. (Full end-to-end test requires simulator + relay — manual only.) 249 + <!-- END_PHASE_4 --> 250 + 251 + ## Additional Considerations 252 + 253 + **`reqwest` TLS on iOS:** Use `rustls-tls` feature with `default-features = false`. Avoids OpenSSL (not available on iOS) and `native-tls` (links against macOS Security framework in a way that conflicts with iOS sysroot). `rustls-tls` bundles its own TLS stack. 254 + 255 + **`security-framework` on iOS vs macOS:** The crate supports both targets. On iOS, some macOS-only APIs are unavailable but the Keychain password APIs (`GenericPassword`) are present on both. Use `security_framework::passwords::get_generic_password` / `set_generic_password`. 256 + 257 + **P-256 key generation:** The `crates/crypto` crate already implements P-256 key generation. The `create_account` command imports this crate via workspace path dependency, not an external crate. 258 + 259 + **`tokio` runtime:** Tauri v2 uses `tokio` internally for async commands. Check if `tokio` is already a transitive dependency before adding it explicitly — it may only need `features = ["rt"]` added if present. 260 + 261 + **DID ceremony placeholder:** Phase 4 emits a placeholder on `nextStep === "did_creation"` (e.g., a simple "Setup complete" screen). The actual DID ceremony is out of scope for MM-144. 262 + 263 + **Claim code error disambiguation:** The relay's `create_mobile_account.rs` returns 404 for invalid/expired codes and 409 for already-redeemed codes. The Rust error mapping reads the HTTP status code, not the body text, to distinguish these cases. The 409 body is used to distinguish `EmailTaken` vs `HandleTaken`.
+1 -1
docs/implementation-plans/2026-03-09-MM-135/phase_01.md
··· 56 56 57 57 **Step 1: Create nix/module.nix** 58 58 59 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/nix/module.nix` with the following contents: 59 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/nix/module.nix` with the following contents: 60 60 61 61 ```nix 62 62 { lib, pkgs, config, ... }:
+1 -1
docs/implementation-plans/2026-03-09-MM-135/phase_02.md
··· 33 33 34 34 **Step 1: Edit flake.nix** 35 35 36 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix`, insert the `nixosModules.default` output after the closing `);` of the `devShells` output and before the `};` that closes the outputs let block. 36 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix`, insert the `nixosModules.default` output after the closing `);` of the `devShells` output and before the `};` that closes the outputs let block. 37 37 38 38 The current end of the outputs block is: 39 39
+2 -2
docs/implementation-plans/2026-03-09-MM-135/phase_03.md
··· 93 93 94 94 **Files:** None (read-only validation) 95 95 96 - All `nix eval` commands below must be run from the repo root (`/Users/jacob.zweifel/workspace/malpercio-dev/ezpds`). 96 + All `nix eval` commands below must be run from the repo root (`/Users/malpercio/workspace/malpercio-dev/ezpds`). 97 97 98 98 **Step 1: Verify ExecStart with minimal config** 99 99 ··· 373 373 374 374 **Step 1: Add nix-check recipe** 375 375 376 - Append to `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/justfile`: 376 + Append to `/Users/malpercio/workspace/malpercio-dev/ezpds/justfile`: 377 377 378 378 ```just 379 379 # Validate NixOS module evaluation (flake structure check).
+2 -2
docs/implementation-plans/2026-03-10-MM-72/phase_01.md
··· 28 28 29 29 **Step 1: Insert sqlx into [workspace.dependencies]** 30 30 31 - Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`. 31 + Open `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`. 32 32 33 33 After the `axum = "0.7"` entry and its blank line, add a `# Database` section: 34 34 ··· 79 79 80 80 **Step 1: Insert sqlx into relay [dependencies]** 81 81 82 - Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 82 + Open `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 83 83 84 84 After the `tower-http = { workspace = true }` entry in `[dependencies]`, add: 85 85
+5 -5
docs/implementation-plans/2026-03-10-MM-72/phase_02.md
··· 41 41 42 42 **Step 1: Add thiserror to [dependencies] and tempfile to [dev-dependencies]** 43 43 44 - Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 44 + Open `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`. 45 45 46 46 After Phase 1, the file looks like: 47 47 ```toml ··· 111 111 **Step 1: Create the directory and SQL file** 112 112 113 113 ```bash 114 - mkdir -p /Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations 114 + mkdir -p /Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations 115 115 ``` 116 116 117 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V001__init.sql` with this exact content: 117 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V001__init.sql` with this exact content: 118 118 119 119 ```sql 120 120 CREATE TABLE server_metadata ( ··· 146 146 147 147 **Step 1: Create the file with the following exact content** 148 148 149 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`: 149 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`: 150 150 151 151 ```rust 152 152 // pattern: Imperative Shell ··· 407 407 408 408 **Step 1: Add mod db declaration** 409 409 410 - Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs`. 410 + Open `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs`. 411 411 412 412 After line 5 (`mod app;`), add: 413 413
+2 -2
docs/implementation-plans/2026-03-10-MM-72/phase_03.md
··· 45 45 46 46 **Step 1: Update AppState struct** 47 47 48 - Open `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`. 48 + Open `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`. 49 49 50 50 The current `AppState` (lines 7–13): 51 51 ```rust ··· 196 196 197 197 **Step 1: Review current main.rs structure** 198 198 199 - The relevant section of `run()` in `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs` currently looks like this (lines 23–45): 199 + The relevant section of `run()` in `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/main.rs` currently looks like this (lines 23–45): 200 200 201 201 ```rust 202 202 async fn run() -> anyhow::Result<()> {
+6 -6
docs/implementation-plans/2026-03-13-MM-89/phase_01.md
··· 53 53 54 54 **Step 1: Add ciborium and data-encoding to workspace Cargo.toml** 55 55 56 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add these two lines after the existing `base64` entry: 56 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add these two lines after the existing `base64` entry: 57 57 58 58 ```toml 59 59 ciborium = "0.2" ··· 62 62 63 63 **Step 2: Add new deps to crates/crypto/Cargo.toml** 64 64 65 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/Cargo.toml`, add to the `[dependencies]` section: 65 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/Cargo.toml`, add to the `[dependencies]` section: 66 66 67 67 ```toml 68 68 ciborium = { workspace = true } ··· 127 127 128 128 **Step 1: Add PlcOperation variant to error.rs** 129 129 130 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/error.rs`, add the new variant to the `CryptoError` enum: 130 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/error.rs`, add the new variant to the `CryptoError` enum: 131 131 132 132 ```rust 133 133 #[derive(Debug, thiserror::Error)] ··· 151 151 152 152 **Step 2: Create crates/crypto/src/plc.rs** 153 153 154 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/plc.rs` with this content: 154 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/plc.rs` with this content: 155 155 156 156 ```rust 157 157 // pattern: Functional Core ··· 550 550 551 551 **Step 3: Add plc module to lib.rs and re-export public types** 552 552 553 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/lib.rs`, add the new module declaration and re-exports: 553 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/src/lib.rs`, add the new module declaration and re-exports: 554 554 555 555 ```rust 556 556 // crypto: signing, Shamir secret sharing, DID operations. ··· 604 604 605 605 **Step 1: Add new contracts to CLAUDE.md** 606 606 607 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add the following to the **Public API contracts** section (add after the existing contracts): 607 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/crypto/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add the following to the **Public API contracts** section (add after the existing contracts): 608 608 609 609 ```markdown 610 610 ### `build_did_plc_genesis_op`
+12 -12
docs/implementation-plans/2026-03-13-MM-89/phase_02.md
··· 55 55 56 56 **Step 1: Add reqwest to workspace Cargo.toml** 57 57 58 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add after the existing entries: 58 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add after the existing entries: 59 59 60 60 ```toml 61 61 reqwest = { version = "0.12", features = ["json"] } ··· 63 63 64 64 **Step 2: Update crates/relay/Cargo.toml** 65 65 66 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`, add to `[dependencies]`: 66 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`, add to `[dependencies]`: 67 67 68 68 ```toml 69 69 reqwest = { workspace = true } ··· 106 106 107 107 **Step 1: Create the migration file** 108 108 109 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V008__did_promotion.sql`: 109 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V008__did_promotion.sql`: 110 110 111 111 ```sql 112 112 -- V008: DID promotion support ··· 154 154 155 155 **Step 2: Add V008 to the MIGRATIONS array in db/mod.rs** 156 156 157 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`, find the `MIGRATIONS` static array. Add the V008 entry after V007: 157 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`, find the `MIGRATIONS` static array. Add the V008 entry after V007: 158 158 159 159 ```rust 160 160 Migration { version: 8, sql: include_str!("migrations/V008__did_promotion.sql") }, ··· 177 177 178 178 **Step 3: Update crates/relay/src/db/CLAUDE.md** 179 179 180 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add to the Key Files section: 180 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/db/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add to the Key Files section: 181 181 182 182 ``` 183 183 - `migrations/V008__did_promotion.sql` - Rebuilds accounts with nullable password_hash (mobile accounts have no password); adds pending_did column to pending_accounts for DID pre-store retry resilience ··· 210 210 211 211 **Step 1: Add plc_directory_url to Config struct** 212 212 213 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/config.rs`: 213 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/common/src/config.rs`: 214 214 215 215 **1a. Add to `Config` struct** (after `signing_key_master_key`): 216 216 ··· 253 253 254 254 **Step 2: Add ErrorCode variants** 255 255 256 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/error.rs`, add to the `ErrorCode` enum (keeping the existing variants unchanged). Match the existing pattern — bare variants with doc comments, no `#[error(...)]` attribute (the enum derives `Serialize` for wire format, not `thiserror::Error`): 256 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/common/src/error.rs`, add to the `ErrorCode` enum (keeping the existing variants unchanged). Match the existing pattern — bare variants with doc comments, no `#[error(...)]` attribute (the enum derives `Serialize` for wire format, not `thiserror::Error`): 257 257 258 258 ```rust 259 259 /// The DID has already been fully promoted to an active account. ··· 308 308 309 309 **Step 1: Add http_client field to AppState** 310 310 311 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`: 311 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`: 312 312 313 313 **1a. Add reqwest use import** (at the top of the file with other imports): 314 314 ··· 552 552 553 553 **Step 2: Create crates/relay/src/routes/create_did.rs** 554 554 555 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/create_did.rs`: 555 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/routes/create_did.rs`: 556 556 557 557 ```rust 558 558 // pattern: Imperative Shell ··· 1368 1368 1369 1369 **Step 3: Add create_did module to routes/mod.rs** 1370 1370 1371 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/mod.rs`, add: 1371 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/routes/mod.rs`, add: 1372 1372 1373 1373 ```rust 1374 1374 pub mod create_did; ··· 1380 1380 1381 1381 **Step 4: Register POST /v1/dids in app.rs router** 1382 1382 1383 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`, in the `app(state: AppState)` function: 1383 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`, in the `app(state: AppState)` function: 1384 1384 1385 1385 **4a. Add the import** at the top of the function body or via `use`: 1386 1386 ··· 1398 1398 1399 1399 **Step 5: Create bruno/create-did.bru** 1400 1400 1401 - Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/bruno/create-did.bru`: 1401 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/bruno/create-did.bru`: 1402 1402 1403 1403 ``` 1404 1404 meta {
+5 -5
docs/implementation-plans/2026-03-14-MM-143/phase_01.md
··· 191 191 192 192 **Step 10: Update `.gitignore` to exclude frontend build artifacts** 193 193 194 - The workspace `.gitignore` is at `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/.gitignore`. Append these lines at the end of the file: 194 + The workspace `.gitignore` is at `/Users/malpercio/workspace/malpercio-dev/ezpds/.gitignore`. Append these lines at the end of the file: 195 195 196 196 ``` 197 197 # SvelteKit / frontend build artifacts ··· 314 314 **Verifies:** MM-143.AC2.1, MM-143.AC2.2, MM-143.AC2.3, MM-143.AC2.4, MM-143.AC2.5 315 315 316 316 **Files:** 317 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml` (add workspace member) 318 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/rust-toolchain.toml` (add iOS targets) 317 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml` (add workspace member) 318 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/rust-toolchain.toml` (add iOS targets) 319 319 320 320 **Step 1: Add `apps/identity-wallet/src-tauri` to workspace members** 321 321 322 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, the current `members` block is: 322 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/Cargo.toml`, the current `members` block is: 323 323 324 324 ```toml 325 325 members = [ ··· 344 344 345 345 **Step 2: Add iOS targets to `rust-toolchain.toml`** 346 346 347 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/rust-toolchain.toml`, the current `targets` line is: 347 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/rust-toolchain.toml`, the current `targets` line is: 348 348 349 349 ```toml 350 350 targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"]
+2 -2
docs/implementation-plans/2026-03-14-MM-143/phase_02.md
··· 47 47 - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` (add tauri and tauri-build deps) 48 48 - Create: `apps/identity-wallet/src-tauri/build.rs` 49 49 - Create: `apps/identity-wallet/src-tauri/tauri.conf.json` 50 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix` (scope `buildDepsOnly` to relay-only packages) 50 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix` (scope `buildDepsOnly` to relay-only packages) 51 51 52 52 **IMPORTANT — ordering:** `tauri::generate_context!()` (added in Task 2) reads `tauri.conf.json` at compile time. Both `tauri.conf.json` and the `tauri-build` build script must exist BEFORE updating `lib.rs` in Task 2. Create all three files in this task, then verify `cargo build` succeeds with the Phase 1 stub `lib.rs` before proceeding to Task 2. 53 53 ··· 144 144 145 145 Fix: scope `buildDepsOnly` to only the 4 relay-related packages. 146 146 147 - In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/flake.nix`, the current `cargoArtifacts` line (line 42) is: 147 + In `/Users/malpercio/workspace/malpercio-dev/ezpds/flake.nix`, the current `cargoArtifacts` line (line 42) is: 148 148 149 149 ```nix 150 150 cargoArtifacts = craneLib.buildDepsOnly commonArgs;
+3 -3
docs/implementation-plans/2026-03-14-MM-143/phase_03.md
··· 39 39 **Verifies:** MM-143.AC5.1 (cargo-tauri in PATH), MM-143.AC5.2 (node 22.x in PATH), MM-143.AC5.3 (pnpm in PATH) 40 40 41 41 **Files:** 42 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/devenv.nix` 42 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/devenv.nix` 43 43 44 44 **Step 1: Update the `packages` list in `devenv.nix`** 45 45 ··· 114 114 **Verifies:** MM-143.AC5.6 (`apps/identity-wallet/src-tauri/gen/` in .gitignore) 115 115 116 116 **Files:** 117 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/.gitignore` 117 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/.gitignore` 118 118 119 119 **Step 1: Append Tauri gen pattern to `.gitignore`** 120 120 ··· 305 305 **Verifies:** MM-143.AC5.5 (root CLAUDE.md contains pointer to apps/identity-wallet/CLAUDE.md) 306 306 307 307 **Files:** 308 - - Modify: `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/CLAUDE.md` 308 + - Modify: `/Users/malpercio/workspace/malpercio-dev/ezpds/CLAUDE.md` 309 309 310 310 **Step 1: Read the current root `CLAUDE.md`** 311 311
+262
docs/implementation-plans/2026-03-15-MM-144/phase_01.md
··· 1 + # MM-144 Onboarding Flow — Implementation Plan 2 + 3 + **Goal:** Add Rust dependencies and implement the Keychain abstraction (`keychain.rs`) and relay HTTP client (`http.rs`) for the identity-wallet Tauri backend. 4 + 5 + **Architecture:** Infrastructure phase only — no IPC command, no frontend changes. Two new Rust modules are added to `src-tauri/src/`, new crate dependencies are added to `src-tauri/Cargo.toml`, and module declarations are added to `lib.rs`. Verification is `cargo build` success. 6 + 7 + **Tech Stack:** Rust stable, Tauri v2, `security-framework` v3 (iOS Keychain), `reqwest` v0.12 (`rustls-tls`), `thiserror` v2 (workspace dep) 8 + 9 + **Scope:** Phase 1 of 4 10 + 11 + **Codebase verified:** 2026-03-15 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This infrastructure phase does not implement user-facing behavior. It creates the building blocks Phase 2 depends on. 18 + 19 + **Verifies:** None — operational verification only (`cargo build`, `cargo clippy`, `cargo fmt --check`) 20 + 21 + --- 22 + 23 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 24 + 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Add Cargo dependencies 27 + 28 + **Files:** 29 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 30 + 31 + **Step 1: Add the new dependencies** 32 + 33 + Open `apps/identity-wallet/src-tauri/Cargo.toml`. The current `[dependencies]` section is: 34 + 35 + ```toml 36 + [dependencies] 37 + tauri = { version = "2", features = [] } 38 + serde = { workspace = true } 39 + serde_json = { workspace = true } 40 + ``` 41 + 42 + Replace with: 43 + 44 + ```toml 45 + [dependencies] 46 + tauri = { version = "2", features = [] } 47 + serde = { workspace = true } 48 + serde_json = { workspace = true } 49 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 50 + security-framework = "3" 51 + thiserror = { workspace = true } 52 + ``` 53 + 54 + **Why `default-features = false` on reqwest:** The default features include `default-tls` (OpenSSL). On iOS there is no OpenSSL; `rustls-tls` bundles its own TLS implementation. 55 + 56 + **Why `thiserror = { workspace = true }`:** The root `Cargo.toml` has `thiserror = "2"` in `[workspace.dependencies]` (used by `crates/crypto`). The `keychain.rs` module uses it for `KeychainError`. 57 + 58 + **Note:** `crypto = { workspace = true }` is NOT added here — it is only needed in Phase 2 when `create_account` calls `generate_p256_keypair`. Adding it now would create unused-dependency warnings. 59 + 60 + **Step 2: Verify the change compiles** 61 + 62 + ```bash 63 + cargo build -p identity-wallet 64 + ``` 65 + 66 + Expected: dependency resolution and download succeed; compilation may fail with "unused import" warnings until modules are created in later tasks — that is fine at this step. If resolution itself fails (e.g., `rustls-tls` feature not found), check reqwest version. 67 + 68 + **Step 3: Commit** 69 + 70 + ```bash 71 + git add apps/identity-wallet/src-tauri/Cargo.toml 72 + git commit -m "chore(identity-wallet): add reqwest, security-framework, thiserror deps" 73 + ``` 74 + <!-- END_TASK_1 --> 75 + 76 + <!-- START_TASK_2 --> 77 + ### Task 2: Create `keychain.rs` 78 + 79 + **Files:** 80 + - Create: `apps/identity-wallet/src-tauri/src/keychain.rs` 81 + 82 + **Step 1: Create the file with the following content** 83 + 84 + ```rust 85 + //! iOS Keychain storage for identity-wallet credentials. 86 + //! 87 + //! All items are stored as `kSecClassGenericPassword` under 88 + //! service `"ezpds-identity-wallet"`. Use the `SERVICE` constant 89 + //! to ensure consistency. 90 + 91 + // Suppressed until Phase 2 wires up the IPC command that calls these functions. 92 + #![allow(dead_code)] 93 + 94 + use security_framework::passwords::{get_generic_password, set_generic_password}; 95 + 96 + pub const SERVICE: &str = "ezpds-identity-wallet"; 97 + 98 + #[derive(Debug, thiserror::Error)] 99 + pub enum KeychainError { 100 + #[error("keychain error: {0}")] 101 + Security(#[from] security_framework::base::Error), 102 + } 103 + 104 + /// Store arbitrary bytes in the Keychain under the given account name. 105 + /// 106 + /// Creates the entry if it doesn't exist, or updates it if it does. 107 + pub fn store_item(account: &str, data: &[u8]) -> Result<(), KeychainError> { 108 + set_generic_password(SERVICE, account, data).map_err(KeychainError::Security) 109 + } 110 + 111 + /// Retrieve bytes from the Keychain for the given account name. 112 + /// 113 + /// Returns `Err` with `errSecItemNotFound` if no entry exists. 114 + pub fn get_item(account: &str) -> Result<Vec<u8>, KeychainError> { 115 + get_generic_password(SERVICE, account).map_err(KeychainError::Security) 116 + } 117 + ``` 118 + 119 + **Why `thiserror`:** Already added to `Cargo.toml` in Task 1. It generates the `Error` impl and `From` conversion automatically. 120 + 121 + **Why `#![allow(dead_code)]`:** `store_item` and `get_item` are not called until Phase 2's `create_account` command. Without this suppression, `cargo clippy --workspace -- -D warnings` would fail in Task 4. Remove this attribute in Phase 2 Task 1 once the functions are in use. 122 + 123 + **Step 2: Commit** 124 + 125 + ```bash 126 + git add apps/identity-wallet/src-tauri/src/keychain.rs 127 + git commit -m "feat(identity-wallet): add keychain module for iOS credential storage" 128 + ``` 129 + <!-- END_TASK_2 --> 130 + 131 + <!-- START_TASK_3 --> 132 + ### Task 3: Create `http.rs` 133 + 134 + **Files:** 135 + - Create: `apps/identity-wallet/src-tauri/src/http.rs` 136 + 137 + **Step 1: Create the file with the following content** 138 + 139 + ```rust 140 + //! Relay HTTP client for identity-wallet. 141 + //! 142 + //! All relay API calls go through `RelayClient`. The base URL is 143 + //! compile-time configured: `http://localhost:8080` in debug builds, 144 + //! `https://relay.ezpds.com` in release builds. 145 + 146 + // Suppressed until Phase 2 wires up the IPC command that calls this client. 147 + #![allow(dead_code)] 148 + 149 + use reqwest::{Client, Response}; 150 + use serde::Serialize; 151 + 152 + #[cfg(debug_assertions)] 153 + const RELAY_BASE_URL: &str = "http://localhost:8080"; 154 + #[cfg(not(debug_assertions))] 155 + const RELAY_BASE_URL: &str = "https://relay.ezpds.com"; 156 + 157 + /// HTTP client for relay API requests. 158 + pub struct RelayClient { 159 + client: Client, 160 + base_url: &'static str, 161 + } 162 + 163 + impl RelayClient { 164 + /// Create a new `RelayClient` with the compile-time base URL. 165 + pub fn new() -> Self { 166 + Self { 167 + client: Client::new(), 168 + base_url: RELAY_BASE_URL, 169 + } 170 + } 171 + 172 + /// POST JSON to `path` (relative, e.g. `"/v1/accounts/mobile"`). 173 + /// 174 + /// Returns the raw `Response` so callers can inspect the status code 175 + /// before attempting to deserialize the body. 176 + pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> reqwest::Result<Response> { 177 + let url = format!("{}{}", self.base_url, path); 178 + self.client.post(&url).json(body).send().await 179 + } 180 + } 181 + 182 + impl Default for RelayClient { 183 + fn default() -> Self { 184 + Self::new() 185 + } 186 + } 187 + ``` 188 + 189 + **Why return raw `Response` instead of deserializing here:** The caller (`create_account` in Phase 2) needs to inspect the HTTP status code first to map error variants (`ExpiredCode`, `EmailTaken`, etc.) before deciding whether to deserialize the success body. If we deserialize here, the error information is lost. 190 + 191 + **Step 2: Commit** 192 + 193 + ```bash 194 + git add apps/identity-wallet/src-tauri/src/http.rs 195 + git commit -m "feat(identity-wallet): add relay HTTP client module" 196 + ``` 197 + <!-- END_TASK_3 --> 198 + 199 + <!-- START_TASK_4 --> 200 + ### Task 4: Declare modules in `lib.rs` and verify build 201 + 202 + **Files:** 203 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 204 + 205 + **Step 1: Add module declarations** 206 + 207 + Open `apps/identity-wallet/src-tauri/src/lib.rs`. The current file begins directly with: 208 + 209 + ```rust 210 + #[tauri::command] 211 + fn greet(name: String) -> String { 212 + format!("Hello, {}!", name) 213 + } 214 + ``` 215 + 216 + Add the module declarations at the very top of the file, before the `#[tauri::command]` attribute: 217 + 218 + ```rust 219 + pub mod http; 220 + pub mod keychain; 221 + 222 + #[tauri::command] 223 + fn greet(name: String) -> String { 224 + format!("Hello, {}!", name) 225 + } 226 + ``` 227 + 228 + Leave the rest of the file unchanged (the `run()` function and `#[cfg(test)]` block stay as-is). 229 + 230 + **Step 2: Verify the full build** 231 + 232 + ```bash 233 + cargo build --workspace 234 + ``` 235 + 236 + Expected: build succeeds with zero errors. `keychain.rs` and `http.rs` have `#![allow(dead_code)]` so unused item warnings are suppressed. 237 + 238 + **Step 3: Verify lints** 239 + 240 + ```bash 241 + cargo clippy --workspace -- -D warnings 242 + ``` 243 + 244 + Expected: passes. `keychain.rs` and `http.rs` already suppress dead_code warnings via `#![allow(dead_code)]`. These suppressions are removed in Phase 2 Task 1 when the functions are called from `create_account`. 245 + 246 + **Step 4: Verify formatting** 247 + 248 + ```bash 249 + cargo fmt --all --check 250 + ``` 251 + 252 + Expected: passes. 253 + 254 + **Step 5: Commit** 255 + 256 + ```bash 257 + git add apps/identity-wallet/src-tauri/src/lib.rs 258 + git commit -m "feat(identity-wallet): register keychain and http modules in lib.rs" 259 + ``` 260 + <!-- END_TASK_4 --> 261 + 262 + <!-- END_SUBCOMPONENT_A -->
+299
docs/implementation-plans/2026-03-15-MM-144/phase_02.md
··· 1 + # MM-144 Onboarding Flow — Phase 2: create_account IPC Command 2 + 3 + **Goal:** Implement the `create_account` Tauri IPC command end-to-end: generate a P-256 keypair, store the private key in the iOS Keychain, POST to the relay, store returned tokens in the Keychain, and return a typed result or typed error. 4 + 5 + **Architecture:** One new `#[tauri::command] async fn create_account` in `lib.rs`. Types declared in the same file. Error variants use `#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]` so they serialize as `{ "code": "EXPIRED_CODE" }` etc., which matches the TypeScript `CreateAccountError` union. 6 + 7 + **Tech Stack:** Rust/Tauri v2 async commands, `crates/crypto` (P-256), `keychain` module from Phase 1, `http` module from Phase 1, `reqwest`, `security-framework`, `serde` 8 + 9 + **Scope:** Phase 2 of 4 10 + 11 + **Codebase verified:** 2026-03-15 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-144.AC2: Account creation succeeds end-to-end 18 + - **MM-144.AC2.1 Success:** Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC 19 + - **MM-144.AC2.2 Success:** The Rust command POSTs to `POST /v1/accounts/mobile` with `email`, `handle`, `claimCode`, `devicePublicKey`, and `platform: "ios"` 20 + - **MM-144.AC2.3 Success:** On 201 response, `device_token` and `session_token` are stored in the iOS Keychain 21 + - **MM-144.AC2.4 Success:** The device P-256 private key is stored in the iOS Keychain before the HTTP request 22 + - **MM-144.AC2.5 Success:** On success, the frontend receives `{ nextStep: "did_creation" }` and advances past the loading screen 23 + 24 + ### MM-144.AC3: Error handling 25 + - **MM-144.AC3.1 Failure:** A relay 404 response (expired claim code) surfaces as `{ code: "EXPIRED_CODE" }` error 26 + - **MM-144.AC3.2 Failure:** A relay 409/`CLAIM_CODE_REDEEMED` surfaces as `{ code: "REDEEMED_CODE" }` error 27 + - **MM-144.AC3.3 Failure:** A relay 409/`ACCOUNT_EXISTS` surfaces as `{ code: "EMAIL_TAKEN" }` error 28 + - **MM-144.AC3.4 Failure:** A relay 409/`HANDLE_TAKEN` surfaces as `{ code: "HANDLE_TAKEN" }` error 29 + - **MM-144.AC3.5 Failure:** A network or server error surfaces as `{ code: "NETWORK_ERROR", message: "..." }` error 30 + 31 + ### MM-144.AC4: iOS Keychain storage 32 + - **MM-144.AC4.1 Success:** `device_token` is stored in the iOS Keychain under account `"device-token"` 33 + - **MM-144.AC4.2 Success:** `session_token` is stored in the iOS Keychain under account `"session-token"` 34 + - **MM-144.AC4.3 Success:** Device P-256 private key bytes are stored in the iOS Keychain under account `"device-private-key"` 35 + 36 + ### MM-144.AC5: Build passes 37 + - **MM-144.AC5.1 Success:** `cargo build --workspace` succeeds after adding the command 38 + 39 + --- 40 + 41 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 42 + 43 + <!-- START_TASK_1 --> 44 + ### Task 1: Implement `create_account` in `lib.rs` 45 + 46 + **Verifies:** MM-144.AC2.1, MM-144.AC2.2, MM-144.AC2.3, MM-144.AC2.4, MM-144.AC2.5, MM-144.AC3.1, MM-144.AC3.2, MM-144.AC3.3, MM-144.AC3.4, MM-144.AC3.5, MM-144.AC4.1, MM-144.AC4.2, MM-144.AC4.3 47 + 48 + **Files:** 49 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 50 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 51 + 52 + **Step 1: Add the `crypto` workspace dependency to `Cargo.toml`** 53 + 54 + Open `apps/identity-wallet/src-tauri/Cargo.toml`. After Phase 1, the `[dependencies]` section ends with `thiserror = { workspace = true }`. Add: 55 + 56 + ```toml 57 + crypto = { workspace = true } 58 + ``` 59 + 60 + The root `Cargo.toml` already has `crypto = { path = "crates/crypto" }` in `[workspace.dependencies]`. 61 + 62 + Also remove the `#![allow(dead_code)]` line from the top of both `src/keychain.rs` and `src/http.rs` — these functions are now in use. 63 + 64 + **Step 2: Add all new types and the `create_account` command to `lib.rs`** 65 + 66 + After Phase 1, `lib.rs` begins with: 67 + ```rust 68 + pub mod http; 69 + pub mod keychain; 70 + 71 + #[tauri::command] 72 + fn greet(name: String) -> String { ... } 73 + ``` 74 + 75 + Add the following block between the `pub mod keychain;` declaration and the `#[tauri::command] fn greet` line: 76 + 77 + ```rust 78 + use crypto::generate_p256_keypair; 79 + use serde::{Deserialize, Serialize}; 80 + 81 + // ── Request / response types ──────────────────────────────────────────────── 82 + 83 + /// JSON body sent to POST /v1/accounts/mobile. 84 + /// Field names match the relay's camelCase deserialization. 85 + #[derive(Serialize)] 86 + #[serde(rename_all = "camelCase")] 87 + struct CreateMobileAccountRequest { 88 + email: String, 89 + handle: String, 90 + device_public_key: String, 91 + platform: String, 92 + claim_code: String, 93 + } 94 + 95 + /// Successful 201 response from the relay. 96 + #[derive(Deserialize)] 97 + #[serde(rename_all = "camelCase")] 98 + struct CreateMobileAccountResponse { 99 + device_token: String, 100 + session_token: String, 101 + next_step: String, 102 + } 103 + 104 + /// Relay error envelope: { "error": { "code": "...", "message": "..." } } 105 + #[derive(Deserialize)] 106 + struct RelayErrorEnvelope { 107 + error: RelayErrorBody, 108 + } 109 + 110 + #[derive(Deserialize)] 111 + struct RelayErrorBody { 112 + code: String, 113 + } 114 + 115 + // ── IPC result / error types (returned to the frontend) ───────────────────── 116 + 117 + /// Successful result returned to the Svelte frontend. 118 + #[derive(Serialize)] 119 + #[serde(rename_all = "camelCase")] 120 + pub struct CreateAccountResult { 121 + pub next_step: String, 122 + } 123 + 124 + /// Typed error returned to the Svelte frontend as a rejected Promise. 125 + /// 126 + /// Serializes as `{ "code": "EXPIRED_CODE" }` (SCREAMING_SNAKE_CASE) so 127 + /// the TypeScript catch block can switch on `error.code`. 128 + #[derive(Debug, Serialize, thiserror::Error)] 129 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 130 + pub enum CreateAccountError { 131 + #[error("claim code has expired")] 132 + ExpiredCode, 133 + #[error("claim code already redeemed")] 134 + RedeemedCode, 135 + #[error("email already taken")] 136 + EmailTaken, 137 + #[error("handle already taken")] 138 + HandleTaken, 139 + #[error("network error: {message}")] 140 + NetworkError { message: String }, 141 + #[error("unknown error: {message}")] 142 + Unknown { message: String }, 143 + } 144 + 145 + // ── IPC command ───────────────────────────────────────────────────────────── 146 + 147 + #[tauri::command] 148 + async fn create_account( 149 + claim_code: String, 150 + email: String, 151 + handle: String, 152 + ) -> Result<CreateAccountResult, CreateAccountError> { 153 + // 1. Generate P-256 device keypair. 154 + let keypair = generate_p256_keypair() 155 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 156 + 157 + // 2. Store private key bytes in Keychain before any network call. 158 + // private_key_bytes is Zeroizing<[u8; 32]>; deref to &[u8] via AsRef. 159 + keychain::store_item("device-private-key", keypair.private_key_bytes.as_ref()) 160 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 161 + 162 + // 3. POST to relay. 163 + let req = CreateMobileAccountRequest { 164 + email, 165 + handle, 166 + device_public_key: keypair.public_key, 167 + platform: "ios".to_string(), 168 + claim_code, 169 + }; 170 + 171 + let resp = http::RelayClient::new() 172 + .post("/v1/accounts/mobile", &req) 173 + .await 174 + .map_err(|e| CreateAccountError::NetworkError { message: e.to_string() })?; 175 + 176 + let status = resp.status(); 177 + 178 + if status.is_success() { 179 + // 4. Deserialize success body. 180 + let body: CreateMobileAccountResponse = resp 181 + .json() 182 + .await 183 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 184 + 185 + // 5. Store tokens in Keychain. 186 + keychain::store_item("device-token", body.device_token.as_bytes()) 187 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 188 + keychain::store_item("session-token", body.session_token.as_bytes()) 189 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 190 + 191 + Ok(CreateAccountResult { next_step: body.next_step }) 192 + } else { 193 + // 6. Map relay error codes to typed variants. 194 + match status.as_u16() { 195 + 404 => Err(CreateAccountError::ExpiredCode), 196 + 409 => { 197 + let envelope: RelayErrorEnvelope = resp 198 + .json() 199 + .await 200 + .map_err(|e| CreateAccountError::Unknown { message: e.to_string() })?; 201 + match envelope.error.code.as_str() { 202 + "CLAIM_CODE_REDEEMED" => Err(CreateAccountError::RedeemedCode), 203 + "ACCOUNT_EXISTS" => Err(CreateAccountError::EmailTaken), 204 + "HANDLE_TAKEN" => Err(CreateAccountError::HandleTaken), 205 + other => Err(CreateAccountError::Unknown { 206 + message: format!("409: {other}"), 207 + }), 208 + } 209 + } 210 + _ => Err(CreateAccountError::NetworkError { 211 + message: format!("HTTP {}", status.as_u16()), 212 + }), 213 + } 214 + } 215 + } 216 + ``` 217 + 218 + **Step 3: Register `create_account` in `generate_handler!`** 219 + 220 + In the `run()` function, change: 221 + 222 + ```rust 223 + .invoke_handler(tauri::generate_handler![greet]) 224 + ``` 225 + 226 + to: 227 + 228 + ```rust 229 + .invoke_handler(tauri::generate_handler![greet, create_account]) 230 + ``` 231 + 232 + **Step 4: Verify build** 233 + 234 + ```bash 235 + cargo build --workspace 236 + ``` 237 + 238 + Expected: build succeeds. The `#![allow(dead_code)]` suppressions were removed from `keychain.rs` and `http.rs` in Step 1 — their functions are now called from `create_account`. If `unused_imports` fires, ensure `use serde::{Deserialize, Serialize};` and `use crypto::generate_p256_keypair;` are only declared once (not duplicated with existing imports). 239 + 240 + **Step 5: Verify lints** 241 + 242 + ```bash 243 + cargo clippy --workspace -- -D warnings 244 + ``` 245 + 246 + Expected: passes. 247 + 248 + **Step 6: Verify formatting** 249 + 250 + ```bash 251 + cargo fmt --all --check 252 + ``` 253 + 254 + Expected: passes. 255 + 256 + **Step 7: Commit** 257 + 258 + ```bash 259 + git add apps/identity-wallet/src-tauri/Cargo.toml apps/identity-wallet/src-tauri/src/lib.rs apps/identity-wallet/src-tauri/src/keychain.rs apps/identity-wallet/src-tauri/src/http.rs 260 + git commit -m "feat(identity-wallet): implement create_account IPC command" 261 + ``` 262 + <!-- END_TASK_1 --> 263 + 264 + <!-- START_TASK_2 --> 265 + ### Task 2: Verify command is reachable from TypeScript (smoke check) 266 + 267 + This task is a build-level verification only — end-to-end HTTP testing requires a running relay and iOS simulator, which is manual. 268 + 269 + **Files:** No changes. 270 + 271 + **Step 1: Confirm the command name matches what ipc.ts will use** 272 + 273 + The Tauri command name for `create_account` (snake_case function) becomes `"create_account"` when called via `invoke()`. Verify this is consistent with the TypeScript wrapper being written in Phase 4 (`invoke('create_account', { claimCode, email, handle })`). 274 + 275 + Tauri v2 maps `claim_code` (Rust parameter) → `claimCode` (JavaScript argument) automatically when the parameter is passed as a camelCase object key. This is the default Tauri v2 behavior for argument deserialization. 276 + 277 + No code change needed — this is a documentation checkpoint. 278 + 279 + **Step 2: Verify the full workspace build one more time** 280 + 281 + ```bash 282 + cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo fmt --all --check 283 + ``` 284 + 285 + Expected: all three commands pass with zero errors. 286 + 287 + **Step 3: Commit (if any minor adjustments were made)** 288 + 289 + If any `#[allow(dead_code)]` attributes were removed or other minor cleanups done: 290 + 291 + ```bash 292 + git add -p 293 + git commit -m "chore(identity-wallet): clean up dead_code suppression after Phase 2" 294 + ``` 295 + 296 + Skip if no changes were needed. 297 + <!-- END_TASK_2 --> 298 + 299 + <!-- END_SUBCOMPONENT_A -->
+592
docs/implementation-plans/2026-03-15-MM-144/phase_03.md
··· 1 + # MM-144 Onboarding Flow — Phase 3: Onboarding Screen Components 2 + 3 + **Goal:** Build the five Svelte screen components for the onboarding wizard. Each component is self-contained: it owns local validation state and communicates to the parent via callback props. No IPC calls in components. 4 + 5 + **Architecture:** Five `.svelte` files in `src/lib/components/onboarding/`. Parent (`+page.svelte`, built in Phase 4) owns form state and passes `$bindable` values + callback props to each screen. Components use Svelte 5 `$props()`, `$bindable()`, and `$derived()`. Scoped CSS only — no CSS framework. 6 + 7 + **Tech Stack:** Svelte 5.25, SvelteKit 2, TypeScript strict, `$state`/`$props`/`$derived`/`$bindable` runes 8 + 9 + **Scope:** Phase 3 of 4 10 + 11 + **Codebase verified:** 2026-03-15 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + UI component ACs are verified by `pnpm build` (TypeScript compilation) and manual visual inspection in the iOS simulator. There is no frontend test framework configured in this project. 18 + 19 + ### MM-144.AC1: Onboarding screens render correctly 20 + - **MM-144.AC1.1 Success:** Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step 21 + - **MM-144.AC1.2 Success:** Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered 22 + - **MM-144.AC1.3 Success:** Email screen shows an email input; the Next button is disabled until a valid email format is entered 23 + - **MM-144.AC1.4 Success:** Handle screen shows a handle input; the Next button is disabled until the handle is non-empty 24 + - **MM-144.AC1.5 Success:** Loading screen shows a spinner and status message while account creation is in progress 25 + - **MM-144.AC1.6 Success:** Each screen's Next/Submit button only advances when its validation condition is met 26 + 27 + **Verification:** `pnpm build` (TypeScript type errors fail the build) + manual visual check in iOS simulator. 28 + 29 + --- 30 + 31 + <!-- START_SUBCOMPONENT_A (tasks 1-6) --> 32 + 33 + <!-- START_TASK_1 --> 34 + ### Task 1: Create the `onboarding` component directory 35 + 36 + **Files:** 37 + - Create: `apps/identity-wallet/src/lib/components/onboarding/.gitkeep` 38 + 39 + **Step 1: Create the directory structure** 40 + 41 + ```bash 42 + mkdir -p apps/identity-wallet/src/lib/components/onboarding 43 + touch apps/identity-wallet/src/lib/components/onboarding/.gitkeep 44 + ``` 45 + 46 + The `.gitkeep` is temporary — it will be replaced by the component files in subsequent tasks and can be deleted after Task 2. 47 + 48 + **Step 2: Commit** 49 + 50 + ```bash 51 + git add apps/identity-wallet/src/lib/components/ 52 + git commit -m "chore(identity-wallet): create onboarding component directory" 53 + ``` 54 + <!-- END_TASK_1 --> 55 + 56 + <!-- START_TASK_2 --> 57 + ### Task 2: Create `WelcomeScreen.svelte` 58 + 59 + **Files:** 60 + - Create: `apps/identity-wallet/src/lib/components/onboarding/WelcomeScreen.svelte` 61 + - Delete: `apps/identity-wallet/src/lib/components/onboarding/.gitkeep` 62 + 63 + **Verifies:** MM-144.AC1.1 64 + 65 + **Step 1: Create the file** 66 + 67 + ```svelte 68 + <script lang="ts"> 69 + let { onstart }: { onstart: () => void } = $props(); 70 + </script> 71 + 72 + <div class="screen"> 73 + <div class="brand"> 74 + <h1>Identity Wallet</h1> 75 + <p class="tagline">Your self-sovereign identity, in your pocket.</p> 76 + </div> 77 + <button class="cta" onclick={onstart}>Get Started</button> 78 + </div> 79 + 80 + <style> 81 + .screen { 82 + display: flex; 83 + flex-direction: column; 84 + align-items: center; 85 + justify-content: center; 86 + height: 100%; 87 + padding: 2rem; 88 + gap: 3rem; 89 + } 90 + 91 + .brand { 92 + display: flex; 93 + flex-direction: column; 94 + align-items: center; 95 + gap: 0.75rem; 96 + text-align: center; 97 + } 98 + 99 + h1 { 100 + font-size: 2rem; 101 + font-weight: 700; 102 + margin: 0; 103 + } 104 + 105 + .tagline { 106 + font-size: 1rem; 107 + color: #6b7280; 108 + margin: 0; 109 + } 110 + 111 + .cta { 112 + width: 100%; 113 + max-width: 320px; 114 + padding: 1rem; 115 + background: #007aff; 116 + color: #fff; 117 + border: none; 118 + border-radius: 12px; 119 + font-size: 1.1rem; 120 + font-weight: 600; 121 + cursor: pointer; 122 + } 123 + </style> 124 + ``` 125 + 126 + **Step 2: Remove `.gitkeep`** 127 + 128 + ```bash 129 + rm apps/identity-wallet/src/lib/components/onboarding/.gitkeep 130 + ``` 131 + 132 + **Step 3: Commit** 133 + 134 + ```bash 135 + git add apps/identity-wallet/src/lib/components/onboarding/ 136 + git commit -m "feat(identity-wallet): add WelcomeScreen component" 137 + ``` 138 + <!-- END_TASK_2 --> 139 + 140 + <!-- START_TASK_3 --> 141 + ### Task 3: Create `ClaimCodeScreen.svelte` 142 + 143 + **Files:** 144 + - Create: `apps/identity-wallet/src/lib/components/onboarding/ClaimCodeScreen.svelte` 145 + 146 + **Verifies:** MM-144.AC1.2, MM-144.AC1.6 147 + 148 + **Step 1: Create the file** 149 + 150 + ```svelte 151 + <script lang="ts"> 152 + let { 153 + value = $bindable(''), 154 + onnext, 155 + error = undefined, 156 + }: { 157 + value: string; 158 + onnext: () => void; 159 + error?: string; 160 + } = $props(); 161 + 162 + let isValid = $derived(value.length === 6); 163 + 164 + function handleInput(e: Event) { 165 + const raw = (e.currentTarget as HTMLInputElement).value; 166 + value = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6); 167 + } 168 + </script> 169 + 170 + <div class="screen"> 171 + <h2>Enter Your Claim Code</h2> 172 + <p class="hint">You'll receive a 6-character code from your administrator.</p> 173 + 174 + <input 175 + type="text" 176 + class="code-input" 177 + class:error={!!error} 178 + maxlength="6" 179 + placeholder="ABC123" 180 + autocomplete="off" 181 + autocorrect="off" 182 + autocapitalize="characters" 183 + spellcheck={false} 184 + {value} 185 + oninput={handleInput} 186 + /> 187 + 188 + {#if error} 189 + <p class="error-text">{error}</p> 190 + {/if} 191 + 192 + <button disabled={!isValid} onclick={onnext}>Next</button> 193 + </div> 194 + 195 + <style> 196 + .screen { 197 + display: flex; 198 + flex-direction: column; 199 + align-items: center; 200 + padding: 2rem; 201 + gap: 1rem; 202 + height: 100%; 203 + justify-content: center; 204 + } 205 + 206 + h2 { 207 + font-size: 1.5rem; 208 + font-weight: 700; 209 + margin: 0; 210 + } 211 + 212 + .hint { 213 + font-size: 0.9rem; 214 + color: #6b7280; 215 + text-align: center; 216 + margin: 0; 217 + } 218 + 219 + .code-input { 220 + width: 100%; 221 + max-width: 320px; 222 + padding: 1rem; 223 + font-size: 1.5rem; 224 + font-family: monospace; 225 + letter-spacing: 0.5rem; 226 + text-align: center; 227 + border: 2px solid #d1d5db; 228 + border-radius: 12px; 229 + text-transform: uppercase; 230 + } 231 + 232 + .code-input.error { 233 + border-color: #ef4444; 234 + } 235 + 236 + .error-text { 237 + color: #ef4444; 238 + font-size: 0.875rem; 239 + margin: 0; 240 + text-align: center; 241 + } 242 + 243 + button { 244 + width: 100%; 245 + max-width: 320px; 246 + padding: 1rem; 247 + background: #007aff; 248 + color: #fff; 249 + border: none; 250 + border-radius: 12px; 251 + font-size: 1rem; 252 + font-weight: 600; 253 + cursor: pointer; 254 + } 255 + 256 + button:disabled { 257 + background: #9ca3af; 258 + cursor: not-allowed; 259 + } 260 + </style> 261 + ``` 262 + 263 + **Why `oninput` + manual value assignment instead of `bind:value`:** The claim code needs auto-uppercase and non-alphanumeric filtering. Controlling the input via `oninput` + `{value}` (one-way, parent-controlled) is cleaner than fighting Svelte's two-way bind for this transformation. The parent still owns the state via `$bindable` — the `handleInput` function mutates `value` directly. 264 + 265 + **Why `error` prop:** The parent passes error messages back to the screen (e.g., "Claim code has expired") after a failed submission. The `error` prop allows the screen to display it without needing to know about the IPC layer. 266 + 267 + **Step 2: Commit** 268 + 269 + ```bash 270 + git add apps/identity-wallet/src/lib/components/onboarding/ClaimCodeScreen.svelte 271 + git commit -m "feat(identity-wallet): add ClaimCodeScreen component" 272 + ``` 273 + <!-- END_TASK_3 --> 274 + 275 + <!-- START_TASK_4 --> 276 + ### Task 4: Create `EmailScreen.svelte` 277 + 278 + **Files:** 279 + - Create: `apps/identity-wallet/src/lib/components/onboarding/EmailScreen.svelte` 280 + 281 + **Verifies:** MM-144.AC1.3, MM-144.AC1.6 282 + 283 + **Step 1: Create the file** 284 + 285 + ```svelte 286 + <script lang="ts"> 287 + let { 288 + value = $bindable(''), 289 + onnext, 290 + error = undefined, 291 + }: { 292 + value: string; 293 + onnext: () => void; 294 + error?: string; 295 + } = $props(); 296 + 297 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 298 + let isValid = $derived(emailRegex.test(value)); 299 + </script> 300 + 301 + <div class="screen"> 302 + <h2>Enter Your Email</h2> 303 + <p class="hint">We'll associate this email with your new account.</p> 304 + 305 + <input 306 + type="email" 307 + class:error={!!error} 308 + placeholder="you@example.com" 309 + autocomplete="email" 310 + inputmode="email" 311 + bind:value 312 + /> 313 + 314 + {#if error} 315 + <p class="error-text">{error}</p> 316 + {/if} 317 + 318 + <button disabled={!isValid} onclick={onnext}>Next</button> 319 + </div> 320 + 321 + <style> 322 + .screen { 323 + display: flex; 324 + flex-direction: column; 325 + align-items: center; 326 + padding: 2rem; 327 + gap: 1rem; 328 + height: 100%; 329 + justify-content: center; 330 + } 331 + 332 + h2 { 333 + font-size: 1.5rem; 334 + font-weight: 700; 335 + margin: 0; 336 + } 337 + 338 + .hint { 339 + font-size: 0.9rem; 340 + color: #6b7280; 341 + text-align: center; 342 + margin: 0; 343 + } 344 + 345 + input { 346 + width: 100%; 347 + max-width: 320px; 348 + padding: 1rem; 349 + font-size: 1rem; 350 + border: 2px solid #d1d5db; 351 + border-radius: 12px; 352 + } 353 + 354 + input.error { 355 + border-color: #ef4444; 356 + } 357 + 358 + .error-text { 359 + color: #ef4444; 360 + font-size: 0.875rem; 361 + margin: 0; 362 + text-align: center; 363 + } 364 + 365 + button { 366 + width: 100%; 367 + max-width: 320px; 368 + padding: 1rem; 369 + background: #007aff; 370 + color: #fff; 371 + border: none; 372 + border-radius: 12px; 373 + font-size: 1rem; 374 + font-weight: 600; 375 + cursor: pointer; 376 + } 377 + 378 + button:disabled { 379 + background: #9ca3af; 380 + cursor: not-allowed; 381 + } 382 + </style> 383 + ``` 384 + 385 + **Step 2: Commit** 386 + 387 + ```bash 388 + git add apps/identity-wallet/src/lib/components/onboarding/EmailScreen.svelte 389 + git commit -m "feat(identity-wallet): add EmailScreen component" 390 + ``` 391 + <!-- END_TASK_4 --> 392 + 393 + <!-- START_TASK_5 --> 394 + ### Task 5: Create `HandleScreen.svelte` 395 + 396 + **Files:** 397 + - Create: `apps/identity-wallet/src/lib/components/onboarding/HandleScreen.svelte` 398 + 399 + **Verifies:** MM-144.AC1.4, MM-144.AC1.6 400 + 401 + **Step 1: Create the file** 402 + 403 + ```svelte 404 + <script lang="ts"> 405 + let { 406 + value = $bindable(''), 407 + onnext, 408 + error = undefined, 409 + }: { 410 + value: string; 411 + onnext: () => void; 412 + error?: string; 413 + } = $props(); 414 + 415 + let isValid = $derived(value.trim().length > 0); 416 + </script> 417 + 418 + <div class="screen"> 419 + <h2>Choose Your Handle</h2> 420 + <p class="hint">This is your unique identifier on the network (e.g. alice.ezpds.com).</p> 421 + 422 + <input 423 + type="text" 424 + class:error={!!error} 425 + placeholder="alice" 426 + autocomplete="off" 427 + autocorrect="off" 428 + autocapitalize="none" 429 + spellcheck={false} 430 + bind:value 431 + /> 432 + 433 + {#if error} 434 + <p class="error-text">{error}</p> 435 + {/if} 436 + 437 + <button disabled={!isValid} onclick={onnext}>Create Account</button> 438 + </div> 439 + 440 + <style> 441 + .screen { 442 + display: flex; 443 + flex-direction: column; 444 + align-items: center; 445 + padding: 2rem; 446 + gap: 1rem; 447 + height: 100%; 448 + justify-content: center; 449 + } 450 + 451 + h2 { 452 + font-size: 1.5rem; 453 + font-weight: 700; 454 + margin: 0; 455 + } 456 + 457 + .hint { 458 + font-size: 0.9rem; 459 + color: #6b7280; 460 + text-align: center; 461 + margin: 0; 462 + } 463 + 464 + input { 465 + width: 100%; 466 + max-width: 320px; 467 + padding: 1rem; 468 + font-size: 1rem; 469 + border: 2px solid #d1d5db; 470 + border-radius: 12px; 471 + } 472 + 473 + input.error { 474 + border-color: #ef4444; 475 + } 476 + 477 + .error-text { 478 + color: #ef4444; 479 + font-size: 0.875rem; 480 + margin: 0; 481 + text-align: center; 482 + } 483 + 484 + button { 485 + width: 100%; 486 + max-width: 320px; 487 + padding: 1rem; 488 + background: #007aff; 489 + color: #fff; 490 + border: none; 491 + border-radius: 12px; 492 + font-size: 1rem; 493 + font-weight: 600; 494 + cursor: pointer; 495 + } 496 + 497 + button:disabled { 498 + background: #9ca3af; 499 + cursor: not-allowed; 500 + } 501 + </style> 502 + ``` 503 + 504 + **Step 2: Commit** 505 + 506 + ```bash 507 + git add apps/identity-wallet/src/lib/components/onboarding/HandleScreen.svelte 508 + git commit -m "feat(identity-wallet): add HandleScreen component" 509 + ``` 510 + <!-- END_TASK_5 --> 511 + 512 + <!-- START_TASK_6 --> 513 + ### Task 6: Create `LoadingScreen.svelte` and verify build 514 + 515 + **Files:** 516 + - Create: `apps/identity-wallet/src/lib/components/onboarding/LoadingScreen.svelte` 517 + 518 + **Verifies:** MM-144.AC1.5 519 + 520 + **Step 1: Create the file** 521 + 522 + ```svelte 523 + <script lang="ts"> 524 + let { 525 + statusText = 'Creating your account…', 526 + }: { 527 + statusText?: string; 528 + } = $props(); 529 + </script> 530 + 531 + <div class="screen"> 532 + <div class="spinner" aria-label="Loading"></div> 533 + <p class="status">{statusText}</p> 534 + </div> 535 + 536 + <style> 537 + .screen { 538 + display: flex; 539 + flex-direction: column; 540 + align-items: center; 541 + justify-content: center; 542 + height: 100%; 543 + gap: 1.5rem; 544 + } 545 + 546 + .spinner { 547 + width: 48px; 548 + height: 48px; 549 + border: 4px solid #e5e7eb; 550 + border-top-color: #007aff; 551 + border-radius: 50%; 552 + animation: spin 0.8s linear infinite; 553 + } 554 + 555 + @keyframes spin { 556 + to { transform: rotate(360deg); } 557 + } 558 + 559 + .status { 560 + font-size: 1rem; 561 + color: #6b7280; 562 + margin: 0; 563 + text-align: center; 564 + } 565 + </style> 566 + ``` 567 + 568 + **Step 2: Commit** 569 + 570 + ```bash 571 + git add apps/identity-wallet/src/lib/components/onboarding/LoadingScreen.svelte 572 + git commit -m "feat(identity-wallet): add LoadingScreen component" 573 + ``` 574 + 575 + **Step 3: Run TypeScript build check** 576 + 577 + ```bash 578 + cd apps/identity-wallet && pnpm build 579 + ``` 580 + 581 + Expected: build succeeds with zero TypeScript errors. The components are not yet imported anywhere (that happens in Phase 4), so no "unused" warnings — SvelteKit does not warn about unused component files. 582 + 583 + **Step 4: Run svelte-check** 584 + 585 + ```bash 586 + cd apps/identity-wallet && pnpm exec svelte-check 587 + ``` 588 + 589 + Expected: zero errors. If any type errors appear, fix them before committing. 590 + <!-- END_TASK_6 --> 591 + 592 + <!-- END_SUBCOMPONENT_A -->
+346
docs/implementation-plans/2026-03-15-MM-144/phase_04.md
··· 1 + # MM-144 Onboarding Flow — Phase 4: State Machine Orchestrator + ipc.ts 2 + 3 + **Goal:** Wire up `+page.svelte` as the five-screen onboarding state machine. Add `createAccount()` to `ipc.ts`. Handle all success and error paths. Replace the `greet` demo entirely. 4 + 5 + **Architecture:** `+page.svelte` owns `step: OnboardingStep` and `form` state. It renders the active screen component, invokes `createAccount()` when the user reaches the loading step, maps typed errors to user-facing messages and step reversions, and advances to a DID ceremony placeholder on success. `ipc.ts` gains the typed `createAccount()` wrapper. 6 + 7 + **Tech Stack:** Svelte 5 runes, TypeScript strict, `@tauri-apps/api/core` `invoke()` 8 + 9 + **Scope:** Phase 4 of 4 10 + 11 + **Codebase verified:** 2026-03-15 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-144.AC1: Onboarding screens render correctly 18 + - **MM-144.AC1.1 Success:** Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step 19 + - **MM-144.AC1.2 Success:** Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered 20 + - **MM-144.AC1.3 Success:** Email screen shows an email input; the Next button is disabled until a valid email format is entered 21 + - **MM-144.AC1.4 Success:** Handle screen shows a handle input; the Next button is disabled until the handle is non-empty 22 + - **MM-144.AC1.5 Success:** Loading screen shows a spinner and status message while account creation is in progress 23 + - **MM-144.AC1.6 Success:** Each screen's Next/Submit button only advances when its validation condition is met 24 + 25 + ### MM-144.AC2: Account creation succeeds end-to-end 26 + - **MM-144.AC2.1 Success:** Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC 27 + - **MM-144.AC2.5 Success:** On success, the frontend receives `{ nextStep: "did_creation" }` and advances past the loading screen 28 + 29 + ### MM-144.AC3: Error handling 30 + - **MM-144.AC3.1 Failure:** A relay 404 (expired claim code) surfaces as "This claim code has expired. Please request a new one." and returns to Claim Code screen 31 + - **MM-144.AC3.2 Failure:** A relay 409/`CLAIM_CODE_REDEEMED` surfaces as "This claim code has already been used." and returns to Claim Code screen 32 + - **MM-144.AC3.3 Failure:** A relay 409/`ACCOUNT_EXISTS` surfaces as "An account with that email already exists." and returns to Email screen 33 + - **MM-144.AC3.4 Failure:** A relay 409/`HANDLE_TAKEN` surfaces as "That handle is taken. Please choose another." and returns to Handle screen 34 + - **MM-144.AC3.5 Failure:** A network or server error surfaces as "Couldn't reach the server. Check your connection." and returns to Handle screen 35 + 36 + ### MM-144.AC5: Build passes 37 + - **MM-144.AC5.2 Success:** `pnpm build` in `apps/identity-wallet/` succeeds after adding new frontend components 38 + 39 + --- 40 + 41 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 42 + 43 + <!-- START_TASK_1 --> 44 + ### Task 1: Add `createAccount` to `ipc.ts` 45 + 46 + **Files:** 47 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` 48 + 49 + **Verifies:** MM-144.AC2.1 50 + 51 + **Step 1: Add the types and wrapper to the existing `ipc.ts`** 52 + 53 + The current file contains only the `greet` wrapper. Add the following below the existing `greet` export: 54 + 55 + ```typescript 56 + // ── create_account ────────────────────────────────────────────────────────── 57 + 58 + export interface CreateAccountParams { 59 + claimCode: string; 60 + email: string; 61 + handle: string; 62 + } 63 + 64 + export interface CreateAccountResult { 65 + nextStep: string; 66 + } 67 + 68 + /** 69 + * Error returned by the `create_account` Rust command. 70 + * 71 + * Serialized as `{ code: "EXPIRED_CODE" }` etc. by the Rust backend. 72 + * The `message` field is present only on NETWORK_ERROR and UNKNOWN variants. 73 + */ 74 + export interface CreateAccountError { 75 + code: 76 + | 'EXPIRED_CODE' 77 + | 'REDEEMED_CODE' 78 + | 'EMAIL_TAKEN' 79 + | 'HANDLE_TAKEN' 80 + | 'NETWORK_ERROR' 81 + | 'UNKNOWN'; 82 + message?: string; 83 + } 84 + 85 + /** 86 + * Create a new account via the relay. 87 + * 88 + * On success, tokens are stored in the iOS Keychain by the Rust backend. 89 + * On failure, the Promise rejects with a `CreateAccountError`. 90 + */ 91 + export const createAccount = ( 92 + params: CreateAccountParams 93 + ): Promise<CreateAccountResult> => 94 + invoke('create_account', params); 95 + ``` 96 + 97 + **Why `invoke('create_account', params)` passes camelCase:** Tauri v2 automatically maps camelCase JavaScript argument keys to snake_case Rust parameter names (`claimCode` → `claim_code`, etc.) during IPC deserialization. The `CreateAccountParams` interface uses camelCase to match JavaScript convention. 98 + 99 + **Step 2: Commit** 100 + 101 + ```bash 102 + git add apps/identity-wallet/src/lib/ipc.ts 103 + git commit -m "feat(identity-wallet): add createAccount IPC wrapper to ipc.ts" 104 + ``` 105 + <!-- END_TASK_1 --> 106 + 107 + <!-- START_TASK_2 --> 108 + ### Task 2: Replace `+page.svelte` with the onboarding state machine 109 + 110 + **Files:** 111 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 112 + 113 + **Verifies:** MM-144.AC1.1–1.6, MM-144.AC2.1, MM-144.AC2.5, MM-144.AC3.1–3.5 114 + 115 + **Step 1: Replace the entire content of `+page.svelte`** 116 + 117 + The current file contains the `greet` demo. Replace it entirely with: 118 + 119 + ```svelte 120 + <script lang="ts"> 121 + import WelcomeScreen from '$lib/components/onboarding/WelcomeScreen.svelte'; 122 + import ClaimCodeScreen from '$lib/components/onboarding/ClaimCodeScreen.svelte'; 123 + import EmailScreen from '$lib/components/onboarding/EmailScreen.svelte'; 124 + import HandleScreen from '$lib/components/onboarding/HandleScreen.svelte'; 125 + import LoadingScreen from '$lib/components/onboarding/LoadingScreen.svelte'; 126 + import { createAccount, type CreateAccountError } from '$lib/ipc'; 127 + 128 + // ── Onboarding step type ───────────────────────────────────────────────── 129 + 130 + type OnboardingStep = 131 + | 'welcome' 132 + | 'claim_code' 133 + | 'email' 134 + | 'handle' 135 + | 'loading' 136 + | 'did_ceremony'; 137 + 138 + // ── State ──────────────────────────────────────────────────────────────── 139 + 140 + let step = $state<OnboardingStep>('welcome'); 141 + let form = $state({ claimCode: '', email: '', handle: '' }); 142 + 143 + /** 144 + * Per-field error messages displayed by each screen. 145 + * Cleared when the user navigates forward to the next step. 146 + */ 147 + let errors = $state<{ claimCode?: string; email?: string; handle?: string }>( 148 + {} 149 + ); 150 + 151 + // ── Navigation helpers ─────────────────────────────────────────────────── 152 + 153 + function goTo(next: OnboardingStep) { 154 + errors = {}; 155 + step = next; 156 + } 157 + 158 + // ── Account creation ───────────────────────────────────────────────────── 159 + 160 + async function submitAccount() { 161 + step = 'loading'; 162 + errors = {}; 163 + 164 + try { 165 + const result = await createAccount({ 166 + claimCode: form.claimCode, 167 + email: form.email, 168 + handle: form.handle, 169 + }); 170 + 171 + if (result.nextStep === 'did_creation') { 172 + step = 'did_ceremony'; 173 + } else { 174 + // Unexpected nextStep value — treat as success and advance anyway. 175 + step = 'did_ceremony'; 176 + } 177 + } catch (raw: unknown) { 178 + // Guard against non-CreateAccountError shapes (e.g. JS runtime errors). 179 + if ( 180 + typeof raw === 'object' && 181 + raw !== null && 182 + 'code' in raw && 183 + typeof (raw as CreateAccountError).code === 'string' 184 + ) { 185 + handleError(raw as CreateAccountError); 186 + } else { 187 + errors.handle = "Couldn't reach the server. Check your connection."; 188 + step = 'handle'; 189 + } 190 + } 191 + } 192 + 193 + function handleError(err: CreateAccountError) { 194 + switch (err.code) { 195 + case 'EXPIRED_CODE': 196 + errors.claimCode = 'This claim code has expired. Please request a new one.'; 197 + step = 'claim_code'; 198 + break; 199 + case 'REDEEMED_CODE': 200 + errors.claimCode = 'This claim code has already been used.'; 201 + step = 'claim_code'; 202 + break; 203 + case 'EMAIL_TAKEN': 204 + errors.email = 'An account with that email already exists.'; 205 + step = 'email'; 206 + break; 207 + case 'HANDLE_TAKEN': 208 + errors.handle = 'That handle is taken. Please choose another.'; 209 + step = 'handle'; 210 + break; 211 + case 'NETWORK_ERROR': 212 + case 'UNKNOWN': 213 + default: 214 + errors.handle = "Couldn't reach the server. Check your connection."; 215 + step = 'handle'; 216 + break; 217 + } 218 + } 219 + </script> 220 + 221 + <div class="app"> 222 + {#if step === 'welcome'} 223 + <WelcomeScreen onstart={() => goTo('claim_code')} /> 224 + {:else if step === 'claim_code'} 225 + <ClaimCodeScreen 226 + bind:value={form.claimCode} 227 + error={errors.claimCode} 228 + onnext={() => goTo('email')} 229 + /> 230 + {:else if step === 'email'} 231 + <EmailScreen 232 + bind:value={form.email} 233 + error={errors.email} 234 + onnext={() => goTo('handle')} 235 + /> 236 + {:else if step === 'handle'} 237 + <HandleScreen 238 + bind:value={form.handle} 239 + error={errors.handle} 240 + onnext={submitAccount} 241 + /> 242 + {:else if step === 'loading'} 243 + <LoadingScreen statusText="Creating your account…" /> 244 + {:else if step === 'did_ceremony'} 245 + <div class="placeholder"> 246 + <h2>Account Created!</h2> 247 + <p>DID ceremony coming soon…</p> 248 + </div> 249 + {/if} 250 + </div> 251 + 252 + <style> 253 + .app { 254 + height: 100vh; 255 + display: flex; 256 + flex-direction: column; 257 + } 258 + 259 + .placeholder { 260 + display: flex; 261 + flex-direction: column; 262 + align-items: center; 263 + justify-content: center; 264 + height: 100%; 265 + gap: 1rem; 266 + text-align: center; 267 + padding: 2rem; 268 + } 269 + </style> 270 + ``` 271 + 272 + **Why `errors` is a `$state` object with optional fields:** Each screen only cares about its own error. Storing errors by field name keeps the state flat and avoids a separate `errorMessage` string that would need context about which screen it belongs to. 273 + 274 + **Why `HandleScreen onnext={submitAccount}`:** The Handle screen is the last data-entry step. Clicking "Create Account" on it directly triggers submission (step transitions to `loading` inside `submitAccount`). There is no separate "submit" step — the transition is handled by `submitAccount` itself. 275 + 276 + **Why `catch (raw: unknown)` cast:** Tauri IPC errors arrive as `unknown` from TypeScript's perspective. Casting to `CreateAccountError` is safe because the Rust backend guarantees the `code` field is always present on error. The `default` case in `handleError` catches any unexpected shape. 277 + 278 + **Step 2: Commit** 279 + 280 + ```bash 281 + git add apps/identity-wallet/src/routes/+page.svelte 282 + git commit -m "feat(identity-wallet): implement onboarding state machine in +page.svelte" 283 + ``` 284 + <!-- END_TASK_2 --> 285 + 286 + <!-- START_TASK_3 --> 287 + ### Task 3: Verify build and run in iOS simulator 288 + 289 + **Files:** No changes. 290 + 291 + **Step 1: TypeScript build check** 292 + 293 + ```bash 294 + cd apps/identity-wallet && pnpm build 295 + ``` 296 + 297 + Expected: build succeeds with zero TypeScript errors and zero Svelte errors. 298 + 299 + **Step 2: svelte-check** 300 + 301 + ```bash 302 + cd apps/identity-wallet && pnpm exec svelte-check 303 + ``` 304 + 305 + Expected: zero errors. 306 + 307 + **Step 3: Rust workspace build** 308 + 309 + ```bash 310 + cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo fmt --all --check 311 + ``` 312 + 313 + Expected: all three pass. 314 + 315 + **Step 4: Manual end-to-end verification in iOS simulator (manual)** 316 + 317 + ```bash 318 + cd apps/identity-wallet && cargo tauri ios dev 319 + ``` 320 + 321 + Walk through each step manually to verify: 322 + 323 + | Step | Verify | 324 + |------|--------| 325 + | Welcome screen | App name + "Get Started" button visible | 326 + | Claim Code screen | Input auto-uppercases; Next disabled until 6 chars | 327 + | Email screen | Next disabled until valid email format | 328 + | Handle screen | Next disabled when empty; button reads "Create Account" | 329 + | Loading screen | Spinner visible during submission | 330 + | Error on expired code | Error message on Claim Code screen, correct text | 331 + | Error on handle taken | Error message on Handle screen, correct text | 332 + | Success | Transitions to "Account Created!" placeholder | 333 + 334 + This step requires a running relay instance (`cargo run -p relay`) with a valid claim code seeded in the database. 335 + 336 + **Step 5: Commit any fixes** 337 + 338 + ```bash 339 + git add -p 340 + git commit -m "fix(identity-wallet): address issues found during simulator testing" 341 + ``` 342 + 343 + Skip if no fixes were needed. 344 + <!-- END_TASK_3 --> 345 + 346 + <!-- END_SUBCOMPONENT_A -->
+163
docs/implementation-plans/2026-03-15-MM-144/test-requirements.md
··· 1 + # MM-144 Test Requirements 2 + 3 + ## Coverage Summary 4 + 5 + | Category | Automated | Human Verification | 6 + |---|---|---| 7 + | AC1 (UI rendering) | 0 | 6 | 8 + | AC2 (account creation) | 3 | 2 | 9 + | AC3 (error handling) | 5 | 5 | 10 + | AC4 (Keychain storage) | 0 | 3 | 11 + | AC5 (build passes) | 2 | 0 | 12 + | **Total** | **10** | **16** | 13 + 14 + Note: Several AC3 criteria appear in both sections. The Rust error-mapping logic (HTTP status code + relay error code -> `CreateAccountError` variant) is unit-testable. The full user-facing flow (error message text displayed on the correct screen) requires iOS Simulator verification because the frontend has no test framework. 15 + 16 + --- 17 + 18 + ## Automated Tests 19 + 20 + ### AC2: Account creation succeeds end-to-end 21 + 22 + | Criterion | Test Type | File | What to Verify | 23 + |---|---|---|---| 24 + | MM-144.AC2.2: The Rust command POSTs to `POST /v1/accounts/mobile` with `email`, `handle`, `claimCode`, `devicePublicKey`, and `platform: "ios"` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateMobileAccountRequest` serializes with the correct camelCase field names and includes all five fields. Construct a `CreateMobileAccountRequest`, serialize to `serde_json::Value`, assert keys are `email`, `handle`, `claimCode`, `devicePublicKey`, `platform` and that `platform` value is `"ios"`. | 25 + | MM-144.AC2.5: On success, the frontend receives `{ nextStep: "did_creation" }` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountResult` serializes correctly. Construct `CreateAccountResult { next_step: "did_creation".into() }`, serialize to JSON, assert the output is `{ "nextStep": "did_creation" }`. | 26 + 27 + ### AC3: Error handling (Rust error variant mapping) 28 + 29 + | Criterion | Test Type | File | What to Verify | 30 + |---|---|---|---| 31 + | MM-144.AC3.1: A relay 404 response maps to `ExpiredCode` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::ExpiredCode` serializes as `{ "code": "EXPIRED_CODE" }`. Construct the variant, serialize to `serde_json::Value`, assert `value["code"] == "EXPIRED_CODE"`. | 32 + | MM-144.AC3.2: A relay 409/`CLAIM_CODE_REDEEMED` maps to `RedeemedCode` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::RedeemedCode` serializes as `{ "code": "REDEEMED_CODE" }`. Same pattern as above. | 33 + | MM-144.AC3.3: A relay 409/`ACCOUNT_EXISTS` maps to `EmailTaken` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::EmailTaken` serializes as `{ "code": "EMAIL_TAKEN" }`. Same pattern. | 34 + | MM-144.AC3.4: A relay 409/`HANDLE_TAKEN` maps to `HandleTaken` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::HandleTaken` serializes as `{ "code": "HANDLE_TAKEN" }`. Same pattern. | 35 + | MM-144.AC3.5: A network or server error maps to `NetworkError` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` (in `#[cfg(test)] mod tests`) | `CreateAccountError::NetworkError { message: "..." }` serializes as `{ "code": "NETWORK_ERROR", "message": "..." }`. Construct the variant with a test message, serialize, assert both fields. | 36 + 37 + ### AC5: Build passes 38 + 39 + | Criterion | Test Type | File | What to Verify | 40 + |---|---|---|---| 41 + | MM-144.AC5.1: `cargo build --workspace` succeeds after adding new Rust dependencies | integration (CI) | N/A (CI pipeline command) | Run `cargo build --workspace && cargo clippy --workspace -- -D warnings && cargo fmt --all --check`. Exit code 0 for all three commands. This is a build-level gate, not a test file. | 42 + | MM-144.AC5.2: `pnpm build` in `apps/identity-wallet/` succeeds after adding new frontend components | integration (CI) | N/A (CI pipeline command) | Run `cd apps/identity-wallet && pnpm build`. Exit code 0. Verifies TypeScript compilation and Svelte component validity. | 43 + 44 + ### Existing relay-side coverage (already written, not new work) 45 + 46 + The relay's `POST /v1/accounts/mobile` endpoint already has comprehensive integration tests in `crates/relay/src/routes/create_mobile_account.rs`. These tests cover the server-side behavior that the mobile client depends on: 47 + 48 + | Relay Behavior | Existing Test | Relevant AC | 49 + |---|---|---| 50 + | 201 response with correct shape | `returns_201_with_correct_shape` | AC2.2, AC2.3 | 51 + | 404 for invalid/expired claim code | `invalid_claim_code_returns_404`, `expired_claim_code_returns_404` | AC3.1 | 52 + | 409 `CLAIM_CODE_REDEEMED` for redeemed code | `already_redeemed_claim_code_returns_409` | AC3.2 | 53 + | 409 `ACCOUNT_EXISTS` for duplicate email | `duplicate_email_in_pending_returns_409`, `duplicate_email_in_accounts_returns_409` | AC3.3 | 54 + | 409 `HANDLE_TAKEN` for duplicate handle | `duplicate_handle_in_pending_returns_409`, `duplicate_handle_in_handles_returns_409` | AC3.4 | 55 + | `nextStep: "did_creation"` in success response | `returns_201_with_correct_shape` (asserts `json["nextStep"] == "did_creation"`) | AC2.5 | 56 + 57 + These tests validate that the relay produces the exact HTTP status codes and error envelope shapes that the Tauri client's error-mapping logic depends on. No new relay tests are needed for MM-144. 58 + 59 + --- 60 + 61 + ## Human Verification 62 + 63 + ### AC1: Onboarding screens render correctly 64 + 65 + | Criterion | Justification | Verification Steps | 66 + |---|---|---| 67 + | MM-144.AC1.1: Welcome screen shows app branding and a "Get Started" CTA button that advances to Claim Code step | No frontend test framework configured (Svelte 5 components, no Vitest/Playwright/Testing Library setup). Visual/interactive behavior requires rendering in a browser or iOS Simulator. | 1. Run `cd apps/identity-wallet && cargo tauri ios dev` to launch in iOS Simulator. 2. Verify the Welcome screen displays "Identity Wallet" heading and "Your self-sovereign identity, in your pocket." tagline. 3. Verify a "Get Started" button is visible. 4. Tap "Get Started" and verify the app advances to the Claim Code screen. | 68 + | MM-144.AC1.2: Claim Code screen shows a 6-character alphanumeric input; the Next button is disabled until exactly 6 characters are entered | Same as above -- input validation behavior (disabled state, character filtering) is DOM-level and requires a rendering context. | 1. From the Welcome screen, tap "Get Started" to reach the Claim Code screen. 2. Verify an input field is displayed with placeholder "ABC123". 3. Verify the "Next" button is disabled (grayed out, not tappable). 4. Type "abc" (3 characters) -- verify the input auto-uppercases to "ABC" and the button remains disabled. 5. Type "12#$34" -- verify non-alphanumeric characters are stripped, leaving "ABC123" (6 chars), and the button becomes enabled. 6. Delete one character -- verify the button disables again. | 69 + | MM-144.AC1.3: Email screen shows an email input; the Next button is disabled until a valid email format is entered | Same as above -- regex-based email validation tied to DOM input state. | 1. Advance to the Email screen (Welcome -> Claim Code with valid 6-char code -> Email). 2. Verify an email input field is displayed with placeholder "you@example.com". 3. Verify the "Next" button is disabled. 4. Type "notanemail" -- verify the button remains disabled. 5. Type "user@example.com" -- verify the button becomes enabled. 6. Clear the field and type "user@" -- verify the button is disabled (incomplete email). | 70 + | MM-144.AC1.4: Handle screen shows a handle input; the Next button is disabled until the handle is non-empty | Same as above -- non-empty validation on a text input. | 1. Advance to the Handle screen (Welcome -> Claim Code -> Email -> Handle). 2. Verify a handle input field is displayed with placeholder "alice". 3. Verify the "Create Account" button is disabled. 4. Type "myhandle" -- verify the button becomes enabled. 5. Clear the field -- verify the button disables again. 6. Type a single space and then delete it -- verify the button remains disabled (trims whitespace). | 71 + | MM-144.AC1.5: Loading screen shows a spinner and status message while account creation is in progress | Loading screen is transient (visible only during the async HTTP call). Requires a running relay or a slow/intercepted network to observe. | 1. Set up a running relay (`cargo run -p relay`) with a valid claim code seeded in the database. 2. Run `cargo tauri ios dev`. 3. Complete all onboarding steps with valid data. 4. On submitting the Handle screen, verify the Loading screen appears with a spinning animation and the text "Creating your account...". 5. (Optional: use Network Link Conditioner on the Simulator to add latency and make the loading screen visible for longer.) | 72 + | MM-144.AC1.6: Each screen's Next/Submit button only advances when its validation condition is met | Aggregate criterion covering all per-screen validation. Fully covered by AC1.2-AC1.4 verification steps above. | 1. Perform all verification steps for AC1.2, AC1.3, and AC1.4. 2. On each screen, attempt to tap the disabled button and verify no navigation occurs. 3. Verify that entering valid data and tapping the button advances to the next screen. | 73 + 74 + ### AC2: Account creation succeeds end-to-end 75 + 76 + | Criterion | Justification | Verification Steps | 77 + |---|---|---| 78 + | MM-144.AC2.1: Valid email, handle, and claim code submission invokes the `create_account` Rust command via Tauri IPC | The IPC bridge between the Svelte frontend and Rust backend requires a running Tauri app in the iOS Simulator. The `invoke()` call cannot be tested without the Tauri runtime. | 1. Start the relay with a seeded claim code: `cargo run -p relay`. 2. Run `cargo tauri ios dev`. 3. Complete the onboarding flow with valid claim code, email, and handle. 4. Verify the app does not remain stuck on the Loading screen (successful IPC call means it either advances to success or shows an error). 5. Check relay logs to confirm a `POST /v1/accounts/mobile` request was received with the correct fields. | 79 + | MM-144.AC2.3: On 201 response, `device_token` and `session_token` are stored in the iOS Keychain | Keychain writes use `security-framework` calling real iOS Security.framework APIs. These APIs are unavailable outside of an Apple platform runtime (no mock framework is configured). | 1. Complete a successful onboarding flow in the iOS Simulator (relay returns 201). 2. After the "Account Created!" placeholder appears, use Xcode's Keychain debugging or `security` CLI in the Simulator shell to verify: `xcrun simctl keychain <device-id> dump` (or attach a debugger and call `SecItemCopyMatching` with service `"ezpds-identity-wallet"` and account `"device-token"`). 3. Verify `device-token` and `session-token` entries exist under service `"ezpds-identity-wallet"`. | 80 + 81 + ### AC3: Error handling (frontend message display and screen navigation) 82 + 83 + | Criterion | Justification | Verification Steps | 84 + |---|---|---| 85 + | MM-144.AC3.1: Expired claim code surfaces as "This claim code has expired. Please request a new one." and returns user to Claim Code screen | The error message text and screen-reversion logic live in the Svelte `+page.svelte` state machine. No frontend test framework is configured to verify DOM content or navigation state. | 1. Start the relay. Do NOT seed a claim code (or seed one that is already expired). 2. Run `cargo tauri ios dev`. 3. Enter a non-existent or expired 6-character claim code, a valid email, and a valid handle. 4. Submit and wait for the loading screen to resolve. 5. Verify the app returns to the Claim Code screen. 6. Verify the error message "This claim code has expired. Please request a new one." is displayed in red below the input. | 86 + | MM-144.AC3.2: Redeemed claim code surfaces as "This claim code has already been used." and returns user to Claim Code screen | Same as above -- frontend error message rendering. | 1. Start the relay and seed a claim code. 2. Use the claim code once (complete a full successful onboarding). 3. Restart the app (kill and relaunch via `cargo tauri ios dev`). 4. Enter the same (now-redeemed) claim code with a different email and handle. 5. Submit and verify the app returns to the Claim Code screen with the message "This claim code has already been used." | 87 + | MM-144.AC3.3: Email taken surfaces as "An account with that email already exists." and returns user to Email screen | Same as above. | 1. Start the relay. Seed two claim codes. 2. Complete onboarding with claim code 1, email "alice@example.com", and handle "alice". 3. Restart the app. 4. Begin onboarding with claim code 2, email "alice@example.com" (same email), and handle "bob". 5. Submit and verify the app returns to the Email screen with the message "An account with that email already exists." | 88 + | MM-144.AC3.4: Handle taken surfaces as "That handle is taken. Please choose another." and returns user to Handle screen | Same as above. | 1. Start the relay. Seed two claim codes. 2. Complete onboarding with claim code 1, email "alice@example.com", and handle "alice.ezpds.com". 3. Restart the app. 4. Begin onboarding with claim code 2, email "bob@example.com", and handle "alice.ezpds.com" (same handle). 5. Submit and verify the app returns to the Handle screen with the message "That handle is taken. Please choose another." | 89 + | MM-144.AC3.5: Network/server error surfaces as "Couldn't reach the server. Check your connection." and returns user to Handle screen | Same as above. | 1. Do NOT start the relay (no server running). 2. Run `cargo tauri ios dev`. 3. Complete all onboarding steps with any valid-looking inputs. 4. Submit and verify the app returns to the Handle screen with the message "Couldn't reach the server. Check your connection." | 90 + 91 + ### AC4: iOS Keychain storage 92 + 93 + | Criterion | Justification | Verification Steps | 94 + |---|---|---| 95 + | MM-144.AC4.1: `device_token` stored under service `"ezpds-identity-wallet"`, account `"device-token"` | Keychain APIs (`security-framework::passwords::set_generic_password`) call real iOS Security.framework. No mock framework is set up; unit testing Keychain operations requires an Apple runtime. The `store_item` function is a thin wrapper with no branching logic worth isolating. | 1. Complete a successful onboarding flow in the iOS Simulator. 2. Pause execution after success (add a breakpoint in `create_account` after the `store_item("device-token", ...)` call, or inspect post-hoc). 3. In the Xcode debugger console, call `SecItemCopyMatching` with query dict `{ kSecClass: kSecClassGenericPassword, kSecAttrService: "ezpds-identity-wallet", kSecAttrAccount: "device-token", kSecReturnData: true }`. 4. Verify data is returned and its base64url-decoded length is 43 characters (base64url encoding of 32 bytes). | 96 + | MM-144.AC4.2: `session_token` stored under service `"ezpds-identity-wallet"`, account `"session-token"` | Same as above. | 1. Same setup as AC4.1. 2. Query the Keychain with account `"session-token"`. 3. Verify data is returned and matches the expected format. | 97 + | MM-144.AC4.3: Device P-256 private key stored under service `"ezpds-identity-wallet"`, account `"device-private-key"` | Same as above. The private key is stored before the HTTP request (AC2.4 ordering), but verifying ordering requires stepping through with a debugger. | 1. Same setup as AC4.1 (or even a failed HTTP request -- the private key is stored before the POST). 2. Query the Keychain with account `"device-private-key"`. 3. Verify data is returned and its length is 32 bytes (raw P-256 private key scalar). 4. (Ordering verification) Set a breakpoint on the `http::RelayClient::new().post(...)` call in `create_account`. When hit, query the Keychain for `"device-private-key"` -- it must already exist, confirming the key was stored before the HTTP request (AC2.4). | 98 + 99 + --- 100 + 101 + ## Test Implementation Notes 102 + 103 + ### Unit tests in `src-tauri/src/lib.rs` 104 + 105 + All automated tests for AC2 and AC3 are serde serialization tests that verify the IPC contract between Rust and TypeScript. They should be added to the existing `#[cfg(test)] mod tests` block in `apps/identity-wallet/src-tauri/src/lib.rs`. These tests do not require any external dependencies (no network, no Keychain, no Tauri runtime). 106 + 107 + Example test structure: 108 + 109 + ```rust 110 + #[cfg(test)] 111 + mod tests { 112 + use super::*; 113 + 114 + // -- AC2.2: Request serialization -- 115 + #[test] 116 + fn create_mobile_account_request_serializes_camel_case() { 117 + let req = CreateMobileAccountRequest { 118 + email: "test@example.com".into(), 119 + handle: "alice".into(), 120 + device_public_key: "pubkey123".into(), 121 + platform: "ios".into(), 122 + claim_code: "ABC123".into(), 123 + }; 124 + let json = serde_json::to_value(&req).unwrap(); 125 + assert_eq!(json["email"], "test@example.com"); 126 + assert_eq!(json["handle"], "alice"); 127 + assert_eq!(json["devicePublicKey"], "pubkey123"); 128 + assert_eq!(json["platform"], "ios"); 129 + assert_eq!(json["claimCode"], "ABC123"); 130 + } 131 + 132 + // -- AC2.5: Result serialization -- 133 + #[test] 134 + fn create_account_result_serializes_camel_case() { 135 + let result = CreateAccountResult { next_step: "did_creation".into() }; 136 + let json = serde_json::to_value(&result).unwrap(); 137 + assert_eq!(json["nextStep"], "did_creation"); 138 + } 139 + 140 + // -- AC3.1-AC3.5: Error variant serialization -- 141 + #[test] 142 + fn error_expired_code_serializes_correctly() { 143 + let err = CreateAccountError::ExpiredCode; 144 + let json = serde_json::to_value(&err).unwrap(); 145 + assert_eq!(json["code"], "EXPIRED_CODE"); 146 + } 147 + 148 + // ... (one test per error variant) 149 + } 150 + ``` 151 + 152 + ### What is NOT testable without additional infrastructure 153 + 154 + 1. **Frontend component rendering** (AC1.*): Requires a frontend test framework (Vitest + Testing Library, or Playwright). Not configured in this project. 155 + 2. **Tauri IPC bridge** (AC2.1): Requires the Tauri runtime to broker `invoke()` calls between the WebView and Rust. Cannot be unit-tested. 156 + 3. **iOS Keychain operations** (AC4.*): Requires Apple Security.framework at runtime. The `keychain.rs` functions are thin wrappers over `security-framework` crate calls with no branching logic. 157 + 4. **End-to-end HTTP flow** (AC2.3, AC2.4): The `create_account` command calls real Keychain APIs and real HTTP endpoints in sequence. Mocking either would require adding `mockall` or a similar framework plus trait-based dependency injection, which is not set up. 158 + 159 + ### Future automation opportunities 160 + 161 + - **Frontend tests**: Adding Vitest + `@testing-library/svelte` would allow testing component validation logic (AC1.2-AC1.4, AC1.6) and error message display (AC3.1-AC3.5 frontend side). 162 + - **Playwright e2e**: Adding Playwright with `@playwright/test` would allow full browser-based testing of the state machine transitions. 163 + - **Keychain mocking**: Extracting the Keychain operations behind a trait and using `mockall` would allow unit-testing the `create_account` command's orchestration logic (key storage ordering, token storage after HTTP success) without an Apple runtime.
+481
docs/implementation-plans/2026-03-18-MM-145/phase_01.md
··· 1 + # MM-145 — P-256 Keypair via Secure Enclave: Implementation Plan 2 + 3 + **Goal:** Introduce `device_key.rs` with the software fallback (simulator + macOS host) implementation and full test coverage. 4 + 5 + **Architecture:** A new Rust module `device_key.rs` with compile-time `#[cfg]`-based dispatch. The simulator + macOS host path uses `crypto::generate_p256_keypair()` for key generation, the `p256` crate for public-key reconstruction and signing, and `multibase` for base58btc encoding. The real-device (SE) path is stubbed with placeholder errors in Phase 1. 6 + 7 + **Tech Stack:** Rust, `p256` 0.13 (ecdsa feature), `multibase` 0.9, `thiserror` 2, `security-framework` (Keychain via `keychain.rs`), `serde` 8 + 9 + **Scope:** Phase 1 of 4 — simulator/macOS host path only. SE path stubs return `KeyGenerationFailed`. 10 + 11 + **Codebase verified:** 2026-03-19 12 + 13 + **cfg deviation from design:** The design doc uses `#[cfg(all(target_vendor = "apple", target_env = "sim"))]` for the simulator path. On macOS host (target of `cargo test`), `target_env` is `""` not `"sim"`, so that cfg would NOT match — tests would fail. This plan extends the software-path cfg to `any(target_os = "macos", all(target_os = "ios", target_env = "sim"))`, and the real-device stub cfg to `all(target_os = "ios", not(target_env = "sim"))`. This makes `cargo test` work on macOS while still providing correct behavior on simulator and real device. 14 + 15 + --- 16 + 17 + ## Acceptance Criteria Coverage 18 + 19 + This phase implements and tests: 20 + 21 + ### MM-145.AC1: get_or_create_device_key returns a valid DevicePublicKey 22 + - **MM-145.AC1.1 Success:** public key multibase string starts with `'z'` and decodes (via base58btc) to exactly 33 bytes 23 + - **MM-145.AC1.2 Success:** two successive calls return identical `multibase` and `key_id` values (idempotent) 24 + - **MM-145.AC1.3 Success:** `key_id` is prefixed with `"did:key:z"` 25 + - **MM-145.AC1.4 Success:** key persists — a fresh call after app restart returns the same public key 26 + 27 + _Coverage note:_ AC1.4 is implicitly covered by AC1.2 on the simulator/macOS path. The `get_or_create()` function is stateless (no in-process caching) — it always calls `keychain::get_item()` on every invocation. Therefore, the idempotency test (which exercises Keychain write then Keychain read in sequence) proves Keychain round-trip correctness, which is the same property needed for persistence across app restarts. Cross-process persistence on real devices is verified manually in Phase 2 (AC2.1). 28 + 29 + ### MM-145.AC3: sign_with_device_key returns a valid ECDSA P-256 signature 30 + - **MM-145.AC3.1 Success:** signing arbitrary data returns exactly 64 bytes 31 + - **MM-145.AC3.2 Success:** signing the same data twice returns identical bytes (RFC 6979 deterministic, simulator path) 32 + - **MM-145.AC3.3 Failure:** calling `sign` before `get_or_create` returns `DeviceKeyError::KeyNotFound` 33 + 34 + ### MM-145.AC4: DeviceKeyError and Tauri commands follow project conventions 35 + - **MM-145.AC4.1 Success:** all `DeviceKeyError` variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` 36 + 37 + --- 38 + 39 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 40 + 41 + <!-- START_TASK_1 --> 42 + ### Task 1: Add dependencies to Cargo.toml and mod declaration to lib.rs 43 + 44 + **Verifies:** None (infrastructure) 45 + 46 + **Files:** 47 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` (lines 15–26, the `[dependencies]` section) 48 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (lines 1–2, the `pub mod` declarations) 49 + 50 + **Step 1: Add `p256` and `multibase` to `apps/identity-wallet/src-tauri/Cargo.toml`** 51 + 52 + The current `[dependencies]` section (lines 15–26) is: 53 + 54 + ```toml 55 + [dependencies] 56 + tauri = { version = "2", features = [] } 57 + serde = { workspace = true } 58 + serde_json = { workspace = true } 59 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 60 + security-framework = "3" 61 + thiserror = { workspace = true } 62 + crypto = { workspace = true } 63 + ``` 64 + 65 + Add two lines at the end of the `[dependencies]` block: 66 + 67 + ```toml 68 + p256 = { workspace = true } 69 + multibase = { workspace = true } 70 + ``` 71 + 72 + Both are already declared at workspace level in the root `Cargo.toml` (lines 61 and 63): 73 + - `p256 = { version = "0.13", features = ["ecdsa"] }` 74 + - `multibase = "0.9"` 75 + 76 + Note: the design doc calls for `bs58` but this repo already has `multibase` in the workspace (used by the `crypto` crate). `multibase::encode(Base::Base58Btc, bytes)` produces the same `'z'` + base58btc output as `'z'.to_string() + &bs58::encode(bytes).into_string()` — no additional dependency needed. 77 + 78 + **Step 2: Add `pub mod device_key;` to `apps/identity-wallet/src-tauri/src/lib.rs`** 79 + 80 + The current mod declarations at the top of `lib.rs` (lines 1–2): 81 + 82 + ```rust 83 + pub mod http; 84 + pub mod keychain; 85 + ``` 86 + 87 + Add a third line: 88 + 89 + ```rust 90 + pub mod http; 91 + pub mod keychain; 92 + pub mod device_key; 93 + ``` 94 + 95 + **Step 3: Verify compilation** 96 + 97 + Run: 98 + ```bash 99 + cargo check -p identity-wallet 2>&1 | head -30 100 + ``` 101 + 102 + Expected: compile error "file not found for module `device_key`" — this is correct; the file doesn't exist yet. The error confirms the mod declaration is wired up. 103 + 104 + **Commit:** Do not commit yet — continue to Task 2. 105 + <!-- END_TASK_1 --> 106 + 107 + <!-- START_TASK_2 --> 108 + ### Task 2: Create `device_key.rs` — types, DeviceKeyError, and function stubs 109 + 110 + **Verifies:** MM-145.AC4.1 (partially — DeviceKeyError exists; full serialization tested in Task 3) 111 + 112 + **Files:** 113 + - Create: `apps/identity-wallet/src-tauri/src/device_key.rs` 114 + 115 + **Step 1: Create `device_key.rs` with types, error enum, and function stubs** 116 + 117 + Create `/Users/malpercio/workspace/malpercio-dev/ezpds/apps/identity-wallet/src-tauri/src/device_key.rs` with the following content: 118 + 119 + ```rust 120 + use serde::Serialize; 121 + 122 + // ── Public types ────────────────────────────────────────────────────────────── 123 + 124 + #[derive(Debug, Serialize)] 125 + pub struct DevicePublicKey { 126 + /// Multibase base58btc-encoded compressed P-256 public key point. 127 + /// Format: 'z' + base58btc(33-byte SEC1 compressed point). 128 + pub multibase: String, 129 + /// Full did:key URI. Format: "did:key:z...". 130 + pub key_id: String, 131 + } 132 + 133 + /// Errors returned by device key operations. 134 + /// 135 + /// Serializes as `{ "code": "SCREAMING_SNAKE_CASE" }` — matches the 136 + /// `CreateAccountError` pattern in `lib.rs`. 137 + #[derive(Debug, Serialize, thiserror::Error)] 138 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 139 + pub enum DeviceKeyError { 140 + #[error("key generation failed")] 141 + KeyGenerationFailed, 142 + #[error("key not found; call get_or_create before sign")] 143 + KeyNotFound, 144 + #[error("signing failed")] 145 + SigningFailed, 146 + /// DER → r||s parse failed (SE path only; not reachable on simulator). 147 + #[error("invalid signature encoding")] 148 + InvalidSignature, 149 + #[error("keychain error: {message}")] 150 + KeychainError { message: String }, 151 + } 152 + 153 + // ── Simulator / macOS host path ─────────────────────────────────────────────── 154 + // 155 + // Covers: 156 + // - macOS (target_os = "macos"): used for `cargo test` on developer machines 157 + // - iOS Simulator (target_os = "ios", target_env = "sim"): no Secure Enclave hardware 158 + // 159 + // Note: the design doc cfg (all(target_vendor = "apple", target_env = "sim")) does not 160 + // match macOS host where target_env = "". We extend to include target_os = "macos" so 161 + // that `cargo test` exercises the software path rather than the SE stubs below. 162 + 163 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 164 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 165 + // Stub — implemented in Task 4. 166 + Err(DeviceKeyError::KeyGenerationFailed) 167 + } 168 + 169 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 170 + pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 171 + // Stub — implemented in Task 4. 172 + Err(DeviceKeyError::KeyGenerationFailed) 173 + } 174 + 175 + // ── Real device (Secure Enclave) stubs ─────────────────────────────────────── 176 + // 177 + // Phase 1 placeholder. The SE path is implemented in Phase 2. 178 + // These compile for `cargo build --target aarch64-apple-ios` but always error. 179 + 180 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 181 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 182 + Err(DeviceKeyError::KeyGenerationFailed) 183 + } 184 + 185 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 186 + pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 187 + Err(DeviceKeyError::KeyGenerationFailed) 188 + } 189 + ``` 190 + 191 + **Step 2: Verify compilation** 192 + 193 + ```bash 194 + cargo check -p identity-wallet 195 + ``` 196 + 197 + Expected: compiles without errors or warnings (the stub functions are `dead_code` only on non-matching targets). 198 + 199 + **Step 3: Confirm tests don't exist yet** 200 + 201 + ```bash 202 + cargo test -p identity-wallet 2>&1 | grep "device_key" 203 + ``` 204 + 205 + Expected: no test output for device_key — confirms no tests yet. 206 + 207 + **Commit:** Do not commit yet — tests come next. 208 + <!-- END_TASK_2 --> 209 + 210 + <!-- END_SUBCOMPONENT_A --> 211 + 212 + <!-- START_SUBCOMPONENT_B (tasks 3-5) --> 213 + 214 + <!-- START_TASK_3 --> 215 + ### Task 3: Write failing tests for all 7 ACs 216 + 217 + **Verifies:** MM-145.AC1.1, MM-145.AC1.2, MM-145.AC1.3, MM-145.AC3.1, MM-145.AC3.2, MM-145.AC3.3, MM-145.AC4.1 218 + 219 + **Files:** 220 + - Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (append `#[cfg(test)]` module) 221 + 222 + **Step 1: Append the test module to `device_key.rs`** 223 + 224 + Append the following block at the end of `device_key.rs`: 225 + 226 + ```rust 227 + #[cfg(test)] 228 + mod tests { 229 + use super::*; 230 + 231 + // Tests use the real macOS Keychain under service "ezpds-identity-wallet". 232 + // Run with `cargo test -- --test-threads=1` to prevent Keychain races between tests. 233 + 234 + // AC1.1 — multibase starts with 'z' and decodes to 33 bytes 235 + #[test] 236 + fn get_or_create_returns_valid_multibase() { 237 + let result = get_or_create().expect("get_or_create should succeed"); 238 + assert!(result.multibase.starts_with('z'), "multibase must start with 'z'"); 239 + let (_, decoded) = multibase::decode(&result.multibase).expect("multibase must decode"); 240 + assert_eq!(decoded.len(), 33, "compressed P-256 point must be 33 bytes"); 241 + } 242 + 243 + // AC1.2 — two successive calls are idempotent 244 + #[test] 245 + fn get_or_create_is_idempotent() { 246 + let first = get_or_create().expect("first call should succeed"); 247 + let second = get_or_create().expect("second call should succeed"); 248 + assert_eq!(first.multibase, second.multibase, "multibase must be stable"); 249 + assert_eq!(first.key_id, second.key_id, "key_id must be stable"); 250 + } 251 + 252 + // AC1.3 — key_id starts with "did:key:z" 253 + #[test] 254 + fn key_id_has_did_key_prefix() { 255 + let result = get_or_create().expect("get_or_create should succeed"); 256 + assert!( 257 + result.key_id.starts_with("did:key:z"), 258 + "key_id must start with 'did:key:z', got: {}", 259 + result.key_id 260 + ); 261 + } 262 + 263 + // AC3.1 — sign returns exactly 64 bytes 264 + #[test] 265 + fn sign_returns_64_bytes() { 266 + get_or_create().expect("must have key before signing"); 267 + let sig = sign(b"test payload").expect("sign should succeed"); 268 + assert_eq!(sig.len(), 64, "raw r||s signature must be 64 bytes"); 269 + } 270 + 271 + // AC3.2 — signing is deterministic (RFC 6979) 272 + #[test] 273 + fn sign_is_deterministic() { 274 + get_or_create().expect("must have key before signing"); 275 + let sig1 = sign(b"determinism test").expect("first sign should succeed"); 276 + let sig2 = sign(b"determinism test").expect("second sign should succeed"); 277 + assert_eq!(sig1, sig2, "same data with same key must produce same signature"); 278 + } 279 + 280 + // AC3.3 — sign before get_or_create returns KeyNotFound 281 + #[test] 282 + fn sign_before_generate_returns_key_not_found() { 283 + // Delete any key left by previous tests to simulate a fresh state. 284 + let _ = crate::keychain::delete_item("device-rotation-key-priv"); 285 + let result = sign(b"should fail"); 286 + assert!( 287 + matches!(result, Err(DeviceKeyError::KeyNotFound)), 288 + "expected KeyNotFound, got: {:?}", 289 + result 290 + ); 291 + } 292 + 293 + // AC4.1 — DeviceKeyError variants serialize as { "code": "SCREAMING_SNAKE_CASE" } 294 + #[test] 295 + fn device_key_error_serializes_as_code() { 296 + let err = DeviceKeyError::KeyGenerationFailed; 297 + let json = serde_json::to_value(&err).unwrap(); 298 + assert_eq!(json["code"], "KEY_GENERATION_FAILED"); 299 + 300 + let err2 = DeviceKeyError::KeyNotFound; 301 + let json2 = serde_json::to_value(&err2).unwrap(); 302 + assert_eq!(json2["code"], "KEY_NOT_FOUND"); 303 + 304 + let err3 = DeviceKeyError::KeychainError { message: "os error".into() }; 305 + let json3 = serde_json::to_value(&err3).unwrap(); 306 + assert_eq!(json3["code"], "KEYCHAIN_ERROR"); 307 + assert_eq!(json3["message"], "os error"); 308 + } 309 + } 310 + ``` 311 + 312 + **Step 2: Verify tests compile and fail** 313 + 314 + ```bash 315 + cargo test -p identity-wallet -- --test-threads=1 2>&1 | tail -30 316 + ``` 317 + 318 + Expected: tests compile but most fail — the stubs return `Err(DeviceKeyError::KeyGenerationFailed)` so: 319 + - `get_or_create_*` tests fail with "get_or_create should succeed: KeyGenerationFailed" 320 + - `sign_*` tests fail similarly 321 + - `device_key_error_serializes_as_code` passes (tests the error enum directly, no Keychain) 322 + - `sign_before_generate_returns_key_not_found` will FAIL (stub returns `KeyGenerationFailed`, not `KeyNotFound`) — this is expected and correct; Task 4 fixes the stub to return `KeyNotFound` 323 + 324 + **Commit:** Do not commit yet — implement in Task 4. 325 + <!-- END_TASK_3 --> 326 + 327 + <!-- START_TASK_4 --> 328 + ### Task 4: Implement simulator/macOS host path — get_or_create and sign 329 + 330 + **Verifies:** MM-145.AC1.1, MM-145.AC1.2, MM-145.AC1.3, MM-145.AC3.1, MM-145.AC3.2, MM-145.AC3.3 331 + 332 + **Files:** 333 + - Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (replace the two simulator stub functions) 334 + 335 + **Implementation:** 336 + 337 + The simulator-path `get_or_create()` and `sign()` stubs from Task 2 (which return `Err(DeviceKeyError::KeyGenerationFailed)`) must be replaced with full implementations. 338 + 339 + **Replace the two simulator-path stubs:** 340 + 341 + Find these stubs in device_key.rs: 342 + 343 + ```rust 344 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 345 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 346 + // Stub — implemented in Task 4. 347 + Err(DeviceKeyError::KeyGenerationFailed) 348 + } 349 + 350 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 351 + pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 352 + // Stub — implemented in Task 4. 353 + Err(DeviceKeyError::KeyGenerationFailed) 354 + } 355 + ``` 356 + 357 + Replace them with: 358 + 359 + ```rust 360 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 361 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 362 + use p256::ecdsa::SigningKey; 363 + 364 + const ACCOUNT: &str = "device-rotation-key-priv"; 365 + 366 + // Try to load existing private key bytes from Keychain. 367 + let private_bytes: Vec<u8> = match crate::keychain::get_item(ACCOUNT) { 368 + Ok(bytes) => bytes, 369 + Err(_) => { 370 + // No key yet — generate a new P-256 keypair via the crypto crate. 371 + let keypair = crypto::generate_p256_keypair() 372 + .map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 373 + // Deref Zeroizing<[u8; 32]> to [u8; 32], then collect as Vec<u8>. 374 + let bytes = keypair.private_key_bytes.to_vec(); 375 + crate::keychain::store_item(ACCOUNT, &bytes) 376 + .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?; 377 + bytes 378 + } 379 + }; 380 + 381 + // Reconstruct the public key from stored private bytes. 382 + let signing_key = SigningKey::from_slice(&private_bytes) 383 + .map_err(|_| DeviceKeyError::KeychainError { message: "invalid stored key bytes".into() })?; 384 + let encoded = signing_key.verifying_key().to_encoded_point(true); // compressed (33 bytes) 385 + let compressed = encoded.as_bytes(); 386 + let multibase = multibase::encode(multibase::Base::Base58Btc, compressed); 387 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128) 388 + // prepended to the compressed point. This matches crates/crypto/src/keys.rs 389 + // `P256_MULTICODEC_PREFIX = &[0x80, 0x24]`, which is `pub(crate)` and cannot be 390 + // imported across crate boundaries — the constant is duplicated intentionally. 391 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 392 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 393 + multikey.extend_from_slice(P256_MULTICODEC); 394 + multikey.extend_from_slice(compressed); 395 + let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey)); 396 + 397 + Ok(DevicePublicKey { multibase, key_id }) 398 + } 399 + 400 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 401 + pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 402 + use p256::ecdsa::{Signature, SigningKey}; 403 + use p256::ecdsa::signature::Signer; 404 + 405 + const ACCOUNT: &str = "device-rotation-key-priv"; 406 + 407 + // If the key doesn't exist, signal that get_or_create must be called first. 408 + let private_bytes = crate::keychain::get_item(ACCOUNT) 409 + .map_err(|_| DeviceKeyError::KeyNotFound)?; 410 + 411 + let signing_key = SigningKey::from_slice(&private_bytes) 412 + .map_err(|_| DeviceKeyError::SigningFailed)?; 413 + 414 + // sign() uses the deterministic Signer impl (RFC 6979 nonce). 415 + // It internally hashes `data` with SHA-256 before signing. 416 + let signature: Signature = signing_key.sign(data); 417 + 418 + // to_bytes() returns a fixed 64-byte GenericArray<u8, U64> (raw r||s). 419 + Ok(signature.to_bytes().to_vec()) 420 + } 421 + ``` 422 + 423 + **Compilation note:** `Zeroizing<[u8; 32]>` implements `Deref<Target = [u8; 32]>`, and `[u8; 32]` coerces to `[u8]` which has `.to_vec()`. If the compiler cannot resolve `.to_vec()` on the deref chain, use: `let bytes = (&*keypair.private_key_bytes as &[u8]).to_vec();` 424 + 425 + **Step 2: Verify cargo check** 426 + 427 + ```bash 428 + cargo check -p identity-wallet 429 + ``` 430 + 431 + Expected: compiles without errors. 432 + <!-- END_TASK_4 --> 433 + 434 + <!-- START_TASK_5 --> 435 + ### Task 5: Run tests and commit Phase 1 436 + 437 + **Verifies:** All 7 ACs listed in this phase 438 + 439 + **Files:** No changes — verification only. 440 + 441 + **Step 1: Run all tests** 442 + 443 + ```bash 444 + cargo test -p identity-wallet -- --test-threads=1 2>&1 445 + ``` 446 + 447 + Expected output (all 7 tests pass): 448 + ``` 449 + running 7 tests 450 + test device_key::tests::device_key_error_serializes_as_code ... ok 451 + test device_key::tests::get_or_create_is_idempotent ... ok 452 + test device_key::tests::get_or_create_returns_valid_multibase ... ok 453 + test device_key::tests::key_id_has_did_key_prefix ... ok 454 + test device_key::tests::sign_before_generate_returns_key_not_found ... ok 455 + test device_key::tests::sign_is_deterministic ... ok 456 + test device_key::tests::sign_returns_64_bytes ... ok 457 + 458 + test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; ... 459 + ``` 460 + 461 + Note: `--test-threads=1` is required because all tests share the same Keychain entry (`"device-rotation-key-priv"` under `"ezpds-identity-wallet"`). Without it, `sign_before_generate_returns_key_not_found` (which deletes the key) may race with `get_or_create_*` tests. 462 + 463 + **Step 2: Run clippy** 464 + 465 + ```bash 466 + cargo clippy -p identity-wallet -- -D warnings 467 + ``` 468 + 469 + Expected: no warnings. 470 + 471 + **Step 3: Commit** 472 + 473 + ```bash 474 + git add apps/identity-wallet/src-tauri/Cargo.toml \ 475 + apps/identity-wallet/src-tauri/src/lib.rs \ 476 + apps/identity-wallet/src-tauri/src/device_key.rs 477 + git commit -m "feat(device-key): add device_key module with simulator/macOS software path and tests" 478 + ``` 479 + <!-- END_TASK_5 --> 480 + 481 + <!-- END_SUBCOMPONENT_B -->
+315
docs/implementation-plans/2026-03-18-MM-145/phase_02.md
··· 1 + # MM-145 — P-256 Keypair via Secure Enclave: Phase 2 2 + 3 + **Goal:** Replace the Phase 1 real-device stubs with a Secure Enclave P-256 implementation using the safe `security_framework` 3.x wrapper. 4 + 5 + **Architecture:** The SE path uses `security_framework::key::SecKey::new()` with `Token::SecureEnclave` for hardware-backed key generation. The generated key is permanent in the SE. The SE private key's `application_label` (SHA1 hash of public key, 20 bytes auto-set by the OS) is stored in the regular Keychain for lookup on subsequent launches. The compressed public key (33 bytes) is also stored in the regular Keychain so `get_or_create()` can return without touching the SE hardware on repeat calls. Signing uses `key.create_signature()` which returns DER (70–72 bytes); this is converted to raw r||s (64 bytes) via `p256::ecdsa::Signature::from_der`. 6 + 7 + **Tech Stack:** `security_framework` 3.7.x with `OSX_10_12` feature (already in Cargo.toml; need feature flag added), `p256` 0.13 (ecdsa feature, for DER→r||s conversion), `multibase` 0.9 8 + 9 + **Scope:** Phase 2 of 4 — real-device Secure Enclave path only. Simulator path (Phase 1) unchanged. 10 + 11 + **Codebase verified:** 2026-03-19 12 + 13 + **Deviation from design doc:** 14 + - Design calls for raw FFI via `security-framework-sys` (`SecKeyCreateRandomKey`, `SecKeyCopyExternalRepresentation`, `SecKeyCreateSignature`). This plan uses the safe `security_framework` 3.x wrapper instead — same functionality, no `unsafe` blocks, no new dependency. 15 + - Design calls for `kSecAttrApplicationTag` as the lookup key. This plan stores the OS-assigned `application_label` (SHA1 of public key) plus the compressed public key bytes in the regular Keychain (`keychain.rs`). This avoids needing `kSecAttrApplicationTag` FFI and is equally stable across app restarts. 16 + - Design says add `security-framework-sys` as explicit dep. This plan adds `OSX_10_12` feature to the existing `security-framework` dep instead — no new crate needed. 17 + 18 + --- 19 + 20 + ## Acceptance Criteria Coverage 21 + 22 + This phase implements (no automated tests — SE hardware required): 23 + 24 + ### MM-145.AC2: Private key material is protected (real device only) 25 + - **MM-145.AC2.1 Success:** key retrieved after cold restart matches key from initial generation (persistence via SE; verified manually on physical device) 26 + - **MM-145.AC2.2 Success:** private key bytes cannot be extracted from the Keychain (`SecKey::new` with `Token::SecureEnclave` is non-extractable by design — verified by attempting `external_representation()` on the private key, which the SE rejects) 27 + 28 + --- 29 + 30 + <!-- START_SUBCOMPONENT_A (task 1) --> 31 + 32 + <!-- START_TASK_1 --> 33 + ### Task 1: Add `OSX_10_12` feature flag to `security-framework` in Cargo.toml 34 + 35 + **Verifies:** None (infrastructure — enables SE APIs) 36 + 37 + **Files:** 38 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` (line 24, the `security-framework` dep) 39 + 40 + **Why:** `security_framework::key::SecKey::new()`, `GenerateKeyOptions`, `Token`, `Algorithm`, and `ItemSearchOptions::load_refs()` are all gated behind the `OSX_10_12` feature in the `security-framework` crate (they were introduced in macOS 10.12 / iOS 10). Without this feature, the SE path code won't compile. 41 + 42 + **Step 1: Update the `security-framework` dep** 43 + 44 + In `apps/identity-wallet/src-tauri/Cargo.toml`, find line 24: 45 + 46 + ```toml 47 + security-framework = "3" 48 + ``` 49 + 50 + Replace with: 51 + 52 + ```toml 53 + security-framework = { version = "3", features = ["OSX_10_12"] } 54 + ``` 55 + 56 + **Step 2: Verify `cargo check` still passes** 57 + 58 + ```bash 59 + cargo check -p identity-wallet 60 + ``` 61 + 62 + Expected: compiles without errors. The `OSX_10_12` feature is additive and backwards-compatible with existing `keychain.rs` usage. 63 + 64 + **Commit:** Do not commit yet — continue to Task 2. 65 + <!-- END_TASK_1 --> 66 + 67 + <!-- END_SUBCOMPONENT_A --> 68 + 69 + <!-- START_SUBCOMPONENT_B (tasks 2-3) --> 70 + 71 + <!-- START_TASK_2 --> 72 + ### Task 2: Implement SE path `get_or_create()` and `sign()` — replace Phase 1 real-device stubs 73 + 74 + **Verifies:** MM-145.AC2.1, MM-145.AC2.2 (manual device verification only) 75 + 76 + **Files:** 77 + - Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (replace the two `#[cfg(all(target_os = "ios", not(target_env = "sim")))]` stub functions) 78 + 79 + **Step 1: Add required imports at the top of `device_key.rs`** 80 + 81 + Add to the top of `device_key.rs` (after the existing `use serde::Serialize;` line): 82 + 83 + ```rust 84 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 85 + use security_framework::{ 86 + access_control::{ProtectionMode, SecAccessControl}, 87 + item::{ItemClass, ItemSearchOptions, KeyClass, Location, Reference, SearchResult}, 88 + key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token}, 89 + }; 90 + ``` 91 + 92 + These imports are gated to the real-device cfg so they don't cause unused-import warnings on macOS/simulator. 93 + 94 + **Step 2: Replace the two real-device stubs** 95 + 96 + Find these stubs in `device_key.rs`: 97 + 98 + ```rust 99 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 100 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 101 + Err(DeviceKeyError::KeyGenerationFailed) 102 + } 103 + 104 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 105 + pub fn sign(_data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 106 + Err(DeviceKeyError::KeyGenerationFailed) 107 + } 108 + ``` 109 + 110 + Replace them with: 111 + 112 + ```rust 113 + /// Account names used to store SE key metadata in the regular Keychain. 114 + /// The SE private key itself is stored in the Secure Enclave and never leaves it. 115 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 116 + const SE_PUB_ACCOUNT: &str = "device-rotation-key-pub"; 117 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 118 + const SE_APP_LABEL_ACCOUNT: &str = "device-rotation-key-app-label"; 119 + 120 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 121 + pub fn get_or_create() -> Result<DevicePublicKey, DeviceKeyError> { 122 + // Fast path: if we already stored the compressed public key, return it directly. 123 + // This avoids SE hardware interaction on every call after first generation. 124 + if let Ok(compressed) = crate::keychain::get_item(SE_PUB_ACCOUNT) { 125 + let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed); 126 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128). 127 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 128 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 129 + multikey.extend_from_slice(P256_MULTICODEC); 130 + multikey.extend_from_slice(&compressed); 131 + let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey)); 132 + return Ok(DevicePublicKey { multibase, key_id }); 133 + } 134 + 135 + // Generate a new SE-backed P-256 key. 136 + // set_location(DataProtectionKeychain) is required — without it, security_framework sets 137 + // kSecAttrIsPermanent = false, meaning the key is not persisted to the Keychain and will 138 + // not survive app restart (breaking AC2.1). 139 + // set_access_control with PRIVATE_KEY_USAGE is required for SE keys — the SE enforces 140 + // that only explicitly-authorized operations can use the private key for signing. 141 + // 142 + // Note: SecAccessControl::create_with_protection takes Option<ProtectionMode> and a raw 143 + // flags u64. The PRIVATE_KEY_USAGE flag is kSecAccessControlPrivateKeyUsage = 1 << 30. 144 + // If the compiler reports an ambiguous type on the flags argument, use `0x4000_0000_u64`. 145 + let access_control = SecAccessControl::create_with_protection( 146 + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), 147 + 1 << 30, // kSecAccessControlPrivateKeyUsage 148 + ) 149 + .map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 150 + 151 + let mut opts = GenerateKeyOptions::default(); 152 + opts.set_key_type(KeyType::ec()) 153 + .set_size_in_bits(256) 154 + .set_token(Token::SecureEnclave) 155 + .set_label("ezpds-device-rotation-key") 156 + .set_location(Location::DataProtectionKeychain) 157 + .set_access_control(access_control); // takes ownership (by value) 158 + 159 + let priv_key = SecKey::new(&opts).map_err(|_| DeviceKeyError::KeyGenerationFailed)?; 160 + 161 + // Retrieve the public key and its external representation. 162 + // SecKeyCopyExternalRepresentation on the *public* key returns the uncompressed 163 + // 65-byte X9.62 point (0x04 || x[32] || y[32]). 164 + let pub_key = priv_key.public_key().ok_or(DeviceKeyError::KeyGenerationFailed)?; 165 + let pub_repr = pub_key 166 + .external_representation() 167 + .ok_or(DeviceKeyError::KeyGenerationFailed)?; 168 + let uncompressed: Vec<u8> = pub_repr.to_vec(); // 65 bytes 169 + 170 + // Compress: prefix byte = 0x02 (even y) or 0x03 (odd y); keep x[32]. 171 + // The last byte of the y coordinate determines parity. 172 + let mut compressed = [0u8; 33]; 173 + compressed[0] = if uncompressed[64] & 1 == 0 { 0x02 } else { 0x03 }; 174 + compressed[1..].copy_from_slice(&uncompressed[1..33]); 175 + 176 + // Store the compressed public key for the fast path on future calls. 177 + crate::keychain::store_item(SE_PUB_ACCOUNT, &compressed) 178 + .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?; 179 + 180 + // Store the application_label (OS-assigned SHA1 of public key, 20 bytes) 181 + // so sign() can locate the SE private key on future app launches. 182 + if let Some(app_label) = priv_key.application_label() { 183 + crate::keychain::store_item(SE_APP_LABEL_ACCOUNT, &app_label) 184 + .map_err(|e| DeviceKeyError::KeychainError { message: e.to_string() })?; 185 + } 186 + 187 + let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed); 188 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128). 189 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 190 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 191 + multikey.extend_from_slice(P256_MULTICODEC); 192 + multikey.extend_from_slice(&compressed); 193 + let key_id = format!("did:key:{}", multibase::encode(multibase::Base::Base58Btc, &multikey)); 194 + Ok(DevicePublicKey { multibase, key_id }) 195 + } 196 + 197 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 198 + pub fn sign(data: &[u8]) -> Result<Vec<u8>, DeviceKeyError> { 199 + use p256::ecdsa::Signature; 200 + 201 + // Load the application_label to look up the SE private key. 202 + let app_label = crate::keychain::get_item(SE_APP_LABEL_ACCOUNT) 203 + .map_err(|_| DeviceKeyError::KeyNotFound)?; 204 + 205 + // Find the SE private key in the Keychain by its application_label. 206 + // load_refs(true) returns SearchResult::Ref(CFType) containing the SecKeyRef. 207 + let mut search = ItemSearchOptions::new(); 208 + search 209 + .class(ItemClass::key()) 210 + .key_class(KeyClass::private()) 211 + .application_label(&app_label) 212 + .load_refs(true) 213 + .limit(1); 214 + 215 + let results = search.search().map_err(|_| DeviceKeyError::KeyNotFound)?; 216 + 217 + // Extract the SecKey from the typed Reference result. 218 + // SearchResult::Ref wraps a Reference enum; Reference::Key holds the already-wrapped SecKey. 219 + // No unsafe code is needed — security_framework handles the SecKeyRef wrapping internally. 220 + let sec_key = match results.into_iter().next() { 221 + Some(SearchResult::Ref(Reference::Key(key))) => key, 222 + _ => return Err(DeviceKeyError::KeyNotFound), 223 + }; 224 + 225 + // create_signature uses kSecKeyAlgorithmECDSASignatureMessageX962SHA256. 226 + // The SE hashes `data` with SHA-256 internally before signing. 227 + // Returns DER-encoded ECDSA signature (70–72 bytes). 228 + let der_sig = sec_key 229 + .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data) 230 + .map_err(|_| DeviceKeyError::SigningFailed)?; 231 + 232 + // Convert DER to raw 64-byte r||s (the format expected by ATProto/did:plc). 233 + // from_der() is a pure parser — it does NOT normalize low-S. Apple's SE may return 234 + // high-S signatures. normalize_s() ensures s <= order/2 as required by ATProto. 235 + let sig = Signature::from_der(&der_sig).map_err(|_| DeviceKeyError::InvalidSignature)?; 236 + let sig = sig.normalize_s().unwrap_or(sig); 237 + Ok(sig.to_bytes().to_vec()) 238 + } 239 + ``` 240 + 241 + **Implementation notes:** 242 + 243 + 1. **`external_representation()` on private SE key:** Returns `None` — the SE rejects export of private key material. Only the public key's `external_representation()` returns data. This verifies AC2.2 by design. 244 + 245 + 2. **`SearchResult::Ref(Reference::Key(key))`:** The `security_framework` 3.7.0 safe API wraps the OS-returned `SecKeyRef` inside a typed `Reference::Key(SecKey)`. No unsafe code is needed — the library handles the cast internally. 246 + 247 + 3. **`set_location` and `set_access_control` on iOS:** `GenerateKeyOptions::to_dictionary()` in `security_framework` 3.7.0 only propagates `kSecAttrIsPermanent` and `kSecAttrAccessControl` into the attributes dictionary under `#[cfg(target_os = "macos")]` — the private key sub-dictionary is skipped on iOS. These calls are included as defensive coding and to document intent, but they have no runtime effect on `aarch64-apple-ios` in this library version. SE keys on iOS are permanent by default through the `Token::SecureEnclave` setting, so AC2.1 is still satisfied. If a future version of `security_framework` corrects this iOS gap, these calls will take effect without code changes. 248 + 249 + 4. **`ItemSearchOptions::limit()`:** Takes a `u32` or `Limit::Max(1)` — check the installed version's API. If `limit()` takes a different type, use `Limit::Max(1)` from `security_framework::item::Limit`. 250 + 251 + 5. **`Algorithm::ECDSASignatureMessageX962SHA256`:** Available from `security_framework::key::Algorithm` with the `OSX_10_12` feature. Verify this exact variant name matches the installed version; the underlying constant is `kSecKeyAlgorithmECDSASignatureMessageX962SHA256`. 252 + 253 + 6. **`normalize_s()` on SE signatures:** Apple's Secure Enclave may return DER signatures where `s > order/2` (high-S). `Signature::from_der` is a pure parser and does not normalize low-S. The `normalize_s()` call ensures the 64-byte r||s output always has low-S as required by the ATProto/did:plc verification protocol. For the simulator path, the `p256` crate's `sign()` trait uses RFC 6979 which inherently produces low-S, so no normalization is needed there. 254 + 255 + **Step 3: Verify `cargo check`** 256 + 257 + ```bash 258 + cargo check -p identity-wallet 259 + ``` 260 + 261 + Expected: compiles without errors. If `security_framework_sys` is not in scope, see note 3 above. 262 + <!-- END_TASK_2 --> 263 + 264 + <!-- START_TASK_3 --> 265 + ### Task 3: Verify simulator tests still pass + iOS build compiles + commit 266 + 267 + **Verifies:** MM-145.AC2.1, MM-145.AC2.2 (build + manual); Phase 1 ACs still pass 268 + 269 + **Files:** No changes — verification only. 270 + 271 + **Step 1: Simulator path tests unchanged** 272 + 273 + ```bash 274 + cargo test -p identity-wallet -- --test-threads=1 2>&1 275 + ``` 276 + 277 + Expected: All 7 Phase 1 tests still pass. The SE path changes are gated behind `#[cfg(all(target_os = "ios", not(target_env = "sim")))]` and do not affect the macOS host test run. 278 + 279 + **Step 2: Verify iOS build compiles (SE path)** 280 + 281 + ```bash 282 + cargo build -p identity-wallet --target aarch64-apple-ios 2>&1 283 + ``` 284 + 285 + Expected: compiles without errors. This confirms the SE path code compiles correctly for the real-device target. 286 + 287 + If the build fails with "error[E0432]: unresolved import `security_framework_sys`": add `security-framework-sys = { version = "2" }` to `apps/identity-wallet/src-tauri/Cargo.toml` and retry. 288 + 289 + **Step 3: Run clippy** 290 + 291 + ```bash 292 + cargo clippy -p identity-wallet -- -D warnings 293 + ``` 294 + 295 + Expected: no warnings. 296 + 297 + **Step 4: Manual device verification (required before Phase 3)** 298 + 299 + On a physical iOS device: 300 + 1. Build and run the app via `cargo tauri ios dev` targeting the device 301 + 2. Call `device_key::get_or_create()` — verify it returns a `DevicePublicKey` with a valid multibase string 302 + 3. Force-kill and relaunch the app (cold restart) 303 + 4. Call `device_key::get_or_create()` again — verify it returns the **same** multibase string (AC2.1) 304 + 5. Try to export the private key via Keychain access — verify it fails (AC2.2, guaranteed by SE hardware) 305 + 306 + **Step 5: Commit** 307 + 308 + ```bash 309 + git add apps/identity-wallet/src-tauri/Cargo.toml \ 310 + apps/identity-wallet/src-tauri/src/device_key.rs 311 + git commit -m "feat(device-key): add Secure Enclave path for real iOS device (Phase 2)" 312 + ``` 313 + <!-- END_TASK_3 --> 314 + 315 + <!-- END_SUBCOMPONENT_B -->
+235
docs/implementation-plans/2026-03-18-MM-145/phase_03.md
··· 1 + # MM-145 — P-256 Keypair via Secure Enclave: Phase 3 2 + 3 + **Goal:** Replace the `crypto::generate_p256_keypair()` call in `create_account` with `device_key::get_or_create()` so the relay receives the SE-backed (or simulator-fallback) public key. 4 + 5 + **Architecture:** `lib.rs`'s `create_account` function currently generates a software P-256 keypair, stores the private bytes in the Keychain, and sends the public key to the relay. After this phase: `create_account` calls `device_key::get_or_create()`, uses `DevicePublicKey.multibase` as `device_public_key`, and removes the explicit private-key Keychain store step (which `device_key` handles internally). Cleanup code that deleted `"device-private-key"` on error is also removed. 6 + 7 + **Tech Stack:** Pure Rust refactoring — no new dependencies. 8 + 9 + **Scope:** Phase 3 of 4 — wiring only. 10 + 11 + **Codebase verified:** 2026-03-19 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-145.AC5: create_account uses the device key 18 + - **MM-145.AC5.1 Success:** `create_account` sends `DevicePublicKey.multibase` as the `device_public_key` field in the relay request (not a freshly-generated software keypair) 19 + 20 + --- 21 + 22 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 23 + 24 + <!-- START_TASK_1 --> 25 + ### Task 1: Write failing test for AC5.1 26 + 27 + **Verifies:** MM-145.AC5.1 28 + 29 + **Files:** 30 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (append to the existing `#[cfg(test)] mod tests` block, around line 198) 31 + 32 + **Why this test:** `create_account` makes a real HTTP call to the relay, so we can't integration-test it without a live server. Instead, we test the device key contract that `create_account` depends on: that `device_key::get_or_create()` is the source of the public key, and that it's stable across calls (so `create_account` will always send the same key for a given device). 33 + 34 + **Step 1: Add test to the existing `#[cfg(test)] mod tests` block in `lib.rs`** 35 + 36 + Find the closing `}` of the existing `mod tests` block (around line 326) and insert before it: 37 + 38 + ```rust 39 + // AC5.1 — create_account will use this key as device_public_key. 40 + // We verify: (a) the key exists and is correctly formatted, (b) it's stable so 41 + // create_account always sends the same device_public_key for this device. 42 + #[test] 43 + fn create_account_uses_device_key_public_key() { 44 + let key = crate::device_key::get_or_create() 45 + .expect("device_key::get_or_create must succeed — create_account depends on it"); 46 + // The relay expects multibase: 'z' + base58btc(33-byte compressed P-256 point). 47 + assert!( 48 + key.multibase.starts_with('z'), 49 + "device_public_key sent to relay must be multibase base58btc ('z' prefix), got: {}", 50 + key.multibase 51 + ); 52 + // Calling again returns the same key — create_account sends consistent device_public_key. 53 + let key2 = crate::device_key::get_or_create() 54 + .expect("second call must also succeed"); 55 + assert_eq!( 56 + key.multibase, 57 + key2.multibase, 58 + "device_public_key must be stable across calls (idempotent)" 59 + ); 60 + } 61 + ``` 62 + 63 + **Step 2: Run the test — verify it passes (the test doesn't depend on the wiring change)** 64 + 65 + ```bash 66 + cargo test -p identity-wallet -- create_account_uses_device_key_public_key --test-threads=1 2>&1 67 + ``` 68 + 69 + Expected: test passes. The test validates the `device_key` contract that `create_account` relies on — it doesn't call `create_account` itself (which requires a live relay). 70 + 71 + **Why this test doesn't call `create_account` directly:** `create_account` makes a real HTTP call to the relay (no mock server in this codebase). Testing the full wiring would require a running relay instance, which is out of scope for unit tests. Instead, this test guards the API contract: it verifies that `device_key::get_or_create()` succeeds and is idempotent (the same values `create_account` will use). If Phase 3's wiring is incorrect at compile time, `cargo check` catches it; at runtime, the manual test in AC5.1 verifies the full flow against a live relay. 72 + 73 + Note: this test passes even before Task 2 because it only tests `device_key::get_or_create()`, not the wiring in `create_account`. It acts as a regression guard — if device key generation breaks, this test will fail, which means `create_account` would also be broken. 74 + <!-- END_TASK_1 --> 75 + 76 + <!-- START_TASK_2 --> 77 + ### Task 2: Update `create_account` to use `device_key::get_or_create()` 78 + 79 + **Verifies:** MM-145.AC5.1 80 + 81 + **Files:** 82 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 83 + 84 + All line numbers reference the current state of the file (before this task's changes). 85 + 86 + **Step 1: Update the import at line 4** 87 + 88 + Find line 4: 89 + ```rust 90 + use crypto::generate_p256_keypair; 91 + ``` 92 + 93 + Replace with (remove the crypto import; `device_key` is already accessible as `pub mod device_key` declared at line 3): 94 + ```rust 95 + // (removed — device_key::get_or_create() replaces crypto::generate_p256_keypair) 96 + ``` 97 + 98 + Actually: just delete line 4 entirely. No replacement import is needed because `device_key` is already declared as a module in this file (`pub mod device_key;` at line 3) and is accessed as `device_key::get_or_create()`. 99 + 100 + **Step 2: Replace the keypair generation (lines 116–118)** 101 + 102 + Find: 103 + ```rust 104 + // 1. Generate P-256 device keypair. 105 + let keypair = generate_p256_keypair().map_err(|e| CreateAccountError::Unknown { 106 + message: e.to_string(), 107 + })?; 108 + ``` 109 + 110 + Replace with: 111 + ```rust 112 + // 1. Get or create the device's SE-backed (or simulator-fallback) P-256 key. 113 + let device_key = device_key::get_or_create().map_err(|e| CreateAccountError::Unknown { 114 + message: e.to_string(), 115 + })?; 116 + ``` 117 + 118 + **Step 3: Remove the private key Keychain store step (lines 120–123)** 119 + 120 + Find and delete: 121 + ```rust 122 + // 2. Store private key bytes in Keychain before any network call. 123 + // private_key_bytes is Zeroizing<[u8; 32]>; deref to &[u8] via AsRef. 124 + keychain::store_item("device-private-key", keypair.private_key_bytes.as_ref()) 125 + .map_err(|_| CreateAccountError::KeychainError)?; 126 + ``` 127 + 128 + This entire block is deleted. `device_key::get_or_create()` handles its own Keychain storage internally. 129 + 130 + **Step 4: Update `device_public_key` in the request (line 129)** 131 + 132 + Find: 133 + ```rust 134 + device_public_key: keypair.public_key, 135 + ``` 136 + 137 + Replace with: 138 + ```rust 139 + device_public_key: device_key.multibase, 140 + ``` 141 + 142 + **Step 5: Remove cleanup calls for `"device-private-key"` (lines 155 and 162–163)** 143 + 144 + In the error-handling blocks after the token Keychain stores, find and remove (two occurrences): 145 + ```rust 146 + let _ = keychain::delete_item("device-private-key"); 147 + ``` 148 + 149 + These lines were cleanup for the private key that `device_key` now manages. The SE-backed device key is intentionally persistent — it should NOT be deleted on account creation failure. 150 + 151 + After Steps 5, 5b, and 5c, the cleanup blocks look like: 152 + ```rust 153 + keychain::store_item("device-token", body.device_token.as_bytes()).map_err(|_| { 154 + // device-token write failed — nothing to clean up; the device key is persistent by design. 155 + CreateAccountError::KeychainError 156 + })?; 157 + 158 + keychain::store_item("session-token", body.session_token.as_bytes()).map_err(|_| { 159 + // Best-effort cleanup: remove the already-written device-token. 160 + let _ = keychain::delete_item("device-token"); 161 + CreateAccountError::KeychainError 162 + })?; 163 + ``` 164 + 165 + **Step 5b: Update the comment on the session-token error handler** 166 + 167 + The original comment ("Best-effort cleanup: also remove the already-written device-token and device-private-key.") references the private key cleanup that was just removed. Update it to reflect only what the block now does: 168 + 169 + Find (in the session-token `map_err` closure): 170 + ```rust 171 + // Best-effort cleanup: also remove the already-written device-token and device-private-key. 172 + ``` 173 + 174 + Replace with: 175 + ```rust 176 + // Best-effort cleanup: remove the already-written device-token. 177 + ``` 178 + 179 + If the original comment does not mention "device-private-key" (exact wording depends on the current file), update it so it only references `device-token`. The intent is: on session-token write failure, we undo the already-written device-token, but we do NOT touch the device key (it is persistent by design). 180 + 181 + **Step 5c: Update the comment on the device-token error handler** 182 + 183 + After removing the `let _ = keychain::delete_item("device-private-key")` from the device-token closure, that block contains no deletion — the old comment "ignore deletion errors" is stale. Update it: 184 + 185 + Find (in the device-token `map_err` closure): 186 + ```rust 187 + // Best-effort cleanup: ignore deletion errors. 188 + ``` 189 + 190 + Replace with: 191 + ```rust 192 + // device-token write failed — nothing to clean up; the device key is persistent by design. 193 + ``` 194 + 195 + **Step 6: Verify `cargo check`** 196 + 197 + ```bash 198 + cargo check -p identity-wallet 199 + ``` 200 + 201 + Expected: compiles without errors. If the compiler warns about unused `crypto` import or unused variables, address them. 202 + <!-- END_TASK_2 --> 203 + 204 + <!-- START_TASK_3 --> 205 + ### Task 3: Verify all tests pass and commit 206 + 207 + **Verifies:** All Phase 1 ACs + MM-145.AC5.1 208 + 209 + **Files:** No changes — verification only. 210 + 211 + **Step 1: Run full test suite** 212 + 213 + ```bash 214 + cargo test -p identity-wallet -- --test-threads=1 2>&1 215 + ``` 216 + 217 + Expected: all tests pass, including the new `create_account_uses_device_key_public_key` test and all 7 Phase 1 tests. 218 + 219 + **Step 2: Run clippy** 220 + 221 + ```bash 222 + cargo clippy -p identity-wallet -- -D warnings 223 + ``` 224 + 225 + Expected: no warnings. Specifically, the removed `use crypto::generate_p256_keypair;` import should not produce an "unused import" warning (it was deleted). 226 + 227 + **Step 3: Commit** 228 + 229 + ```bash 230 + git add apps/identity-wallet/src-tauri/src/lib.rs 231 + git commit -m "feat(create-account): use device_key::get_or_create() for device public key" 232 + ``` 233 + <!-- END_TASK_3 --> 234 + 235 + <!-- END_SUBCOMPONENT_A -->
+277
docs/implementation-plans/2026-03-18-MM-145/phase_04.md
··· 1 + # MM-145 — P-256 Keypair via Secure Enclave: Phase 4 2 + 3 + **Goal:** Expose `device_key::get_or_create()` and `device_key::sign()` as Tauri IPC commands, and add typed TypeScript wrappers in `ipc.ts`. 4 + 5 + **Architecture:** Two new async Tauri commands (`get_or_create_device_key`, `sign_with_device_key`) are added to `lib.rs` and registered in `generate_handler![]`. `DevicePublicKey` gains `#[serde(rename_all = "camelCase")]` so `key_id` serializes to `keyId`. Typed TypeScript wrappers in `ipc.ts` convert `Vec<u8>` ↔ `Uint8Array` at the IPC boundary. 6 + 7 + **Tech Stack:** Rust (Tauri v2 IPC), TypeScript (`@tauri-apps/api/core` invoke) 8 + 9 + **Scope:** Phase 4 of 4 — IPC wiring only. 10 + 11 + **Codebase verified:** 2026-03-19 12 + 13 + **IPC binary data behavior (Tauri v2):** 14 + - `Vec<u8>` parameters: JavaScript must pass `number[]`, NOT `Uint8Array` nested in an object — Tauri's JSON deserializer does not auto-convert `Uint8Array` inside object properties. 15 + - `Vec<u8>` return values: JavaScript receives `number[]` from `invoke()` with standard `#[tauri::command]`. 16 + - The TypeScript wrappers convert at the boundary: `Array.from(uint8array)` outbound, `new Uint8Array(numbers)` inbound. 17 + 18 + --- 19 + 20 + ## Acceptance Criteria Coverage 21 + 22 + ### MM-145.AC4: DeviceKeyError and Tauri commands follow project conventions 23 + - **MM-145.AC4.1 Success:** all `DeviceKeyError` variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` (tested in Phase 1; verified again by Phase 4 serialization test for `DevicePublicKey`) 24 + - **MM-145.AC4.2 Success:** frontend `ipc.ts` can call `getOrCreateDeviceKey()` and `signWithDeviceKey()` and receive correct TypeScript types (manual verification on simulator) 25 + 26 + --- 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Write failing serialization test for `DevicePublicKey` camelCase 32 + 33 + **Verifies:** MM-145.AC4.1 (partially — ensures DevicePublicKey serializes correctly for Tauri IPC) 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (add one test to the existing `#[cfg(test)] mod tests` block) 37 + 38 + **Why this task first:** `DevicePublicKey` currently has no `#[serde(rename_all = "camelCase")]` attribute. Without it, `key_id` serializes as `"key_id"` in JSON — the TypeScript side would receive `key_id` not `keyId`, breaking the TypeScript type definition. The test exposes this gap before we fix it. 39 + 40 + **Step 1: Add a failing test to the `#[cfg(test)] mod tests` block in `device_key.rs`** 41 + 42 + Find the closing `}` of the `mod tests` block (after the `device_key_error_serializes_as_code` test) and insert before it: 43 + 44 + ```rust 45 + // Ensures DevicePublicKey serializes key_id as keyId (camelCase) for Tauri IPC. 46 + // Without #[serde(rename_all = "camelCase")], this test fails. 47 + #[test] 48 + fn device_public_key_serializes_camel_case() { 49 + let key = DevicePublicKey { 50 + multibase: "zTest".into(), 51 + key_id: "did:key:zTest".into(), 52 + }; 53 + let json = serde_json::to_value(&key).unwrap(); 54 + assert_eq!(json["multibase"], "zTest"); 55 + assert_eq!(json["keyId"], "did:key:zTest", "key_id must serialize as keyId for TypeScript"); 56 + // Confirm the snake_case version is NOT present. 57 + assert!(json.get("key_id").is_none(), "key_id must not appear as snake_case in JSON"); 58 + } 59 + ``` 60 + 61 + **Step 2: Run the test — verify it FAILS** 62 + 63 + ```bash 64 + cargo test -p identity-wallet -- device_public_key_serializes_camel_case --test-threads=1 2>&1 65 + ``` 66 + 67 + Expected: test fails because `DevicePublicKey` does not yet have `#[serde(rename_all = "camelCase")]`. 68 + <!-- END_TASK_1 --> 69 + 70 + <!-- START_TASK_2 --> 71 + ### Task 2: Add `#[serde(rename_all = "camelCase")]` to `DevicePublicKey` and add Tauri commands to `lib.rs` 72 + 73 + **Verifies:** MM-145.AC4.1, MM-145.AC4.2 74 + 75 + **Files:** 76 + - Modify: `apps/identity-wallet/src-tauri/src/device_key.rs` (add serde attribute to `DevicePublicKey`) 77 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add two Tauri commands, update `generate_handler![]`) 78 + 79 + **Step 1: Add `#[serde(rename_all = "camelCase")]` to `DevicePublicKey` in `device_key.rs`** 80 + 81 + Find: 82 + ```rust 83 + #[derive(Debug, Serialize)] 84 + pub struct DevicePublicKey { 85 + ``` 86 + 87 + Replace with: 88 + ```rust 89 + #[derive(Debug, Serialize)] 90 + #[serde(rename_all = "camelCase")] 91 + pub struct DevicePublicKey { 92 + ``` 93 + 94 + **Step 2: Verify the previously failing test now passes** 95 + 96 + ```bash 97 + cargo test -p identity-wallet -- device_public_key_serializes_camel_case --test-threads=1 2>&1 98 + ``` 99 + 100 + Expected: passes. `keyId` appears in JSON; `key_id` does not. 101 + 102 + **Step 3: Add two new Tauri commands to `lib.rs`** 103 + 104 + Add the following two functions anywhere in `lib.rs` after the existing `create_account` function (before the `pub fn run()` function). These are thin wrappers — all logic lives in `device_key`: 105 + 106 + ```rust 107 + #[tauri::command] 108 + async fn get_or_create_device_key() -> Result<device_key::DevicePublicKey, device_key::DeviceKeyError> { 109 + device_key::get_or_create() 110 + } 111 + 112 + #[tauri::command] 113 + async fn sign_with_device_key(data: Vec<u8>) -> Result<Vec<u8>, device_key::DeviceKeyError> { 114 + device_key::sign(&data) 115 + } 116 + ``` 117 + 118 + **Step 4: Register the new commands in `generate_handler![]` (line 193)** 119 + 120 + Find: 121 + ```rust 122 + .invoke_handler(tauri::generate_handler![create_account]) 123 + ``` 124 + 125 + Replace with: 126 + ```rust 127 + .invoke_handler(tauri::generate_handler![ 128 + create_account, 129 + get_or_create_device_key, 130 + sign_with_device_key, 131 + ]) 132 + ``` 133 + 134 + **Step 5: Verify `cargo check`** 135 + 136 + ```bash 137 + cargo check -p identity-wallet 138 + ``` 139 + 140 + Expected: compiles without errors or warnings. 141 + <!-- END_TASK_2 --> 142 + 143 + <!-- END_SUBCOMPONENT_A --> 144 + 145 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 146 + 147 + <!-- START_TASK_3 --> 148 + ### Task 3: Add TypeScript wrappers to `ipc.ts` 149 + 150 + **Verifies:** MM-145.AC4.2 151 + 152 + **Files:** 153 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` (append after the `createAccount` export) 154 + 155 + **Step 1: Append to `apps/identity-wallet/src/lib/ipc.ts`** 156 + 157 + Phases 1–3 do not touch `ipc.ts`, so line 47 is stable and is the insertion point. After line 47 (the end of the `createAccount` export), append: 158 + 159 + ```typescript 160 + // ── Device Key types ────────────────────────────────────────────────────────── 161 + 162 + /** 163 + * Device public key returned by the `get_or_create_device_key` Rust command. 164 + * Matches DevicePublicKey struct with #[serde(rename_all = "camelCase")]. 165 + */ 166 + export type DevicePublicKey = { 167 + /** 'z' + base58btc(33-byte compressed P-256 public key point). */ 168 + multibase: string; 169 + /** Full did:key URI: 'did:key:z...' */ 170 + keyId: string; 171 + }; 172 + 173 + /** 174 + * Error returned by device key commands. 175 + * 176 + * Serialized as `{ code: "KEY_GENERATION_FAILED" }` etc. by the Rust backend. 177 + * `message` is present only for KEYCHAIN_ERROR. 178 + */ 179 + export type DeviceKeyError = { 180 + code: 181 + | 'KEY_GENERATION_FAILED' 182 + | 'KEY_NOT_FOUND' 183 + | 'SIGNING_FAILED' 184 + | 'INVALID_SIGNATURE' 185 + | 'KEYCHAIN_ERROR'; 186 + message?: string; 187 + }; 188 + 189 + // ── get_or_create_device_key ───────────────────────────────────────────────── 190 + 191 + /** 192 + * Get or create the device's SE-backed (or simulator-fallback) P-256 keypair. 193 + * 194 + * Idempotent — returns the same key on every call for a given device. 195 + * On failure, the Promise rejects with a `DeviceKeyError`. 196 + */ 197 + export const getOrCreateDeviceKey = (): Promise<DevicePublicKey> => 198 + invoke('get_or_create_device_key'); 199 + 200 + // ── sign_with_device_key ───────────────────────────────────────────────────── 201 + 202 + /** 203 + * Sign arbitrary bytes using the device's SE-backed (or simulator-fallback) P-256 key. 204 + * 205 + * Returns the raw 64-byte ECDSA r||s signature as a Uint8Array. 206 + * 207 + * IMPORTANT: `data` is converted to `number[]` before passing to Tauri's IPC 208 + * because Tauri v2's JSON deserializer cannot accept a `Uint8Array` nested inside 209 + * an object property — it must be a plain number array. See tauri#10336. 210 + * 211 + * On failure, the Promise rejects with a `DeviceKeyError` (code: KEY_NOT_FOUND 212 + * if `getOrCreateDeviceKey` has never been called for this device). 213 + */ 214 + export const signWithDeviceKey = (data: Uint8Array): Promise<Uint8Array> => 215 + (invoke('sign_with_device_key', { data: Array.from(data) }) as Promise<number[]>).then( 216 + (bytes) => new Uint8Array(bytes), 217 + ); 218 + ``` 219 + 220 + **Step 2: Verify the TypeScript file is syntactically valid** 221 + 222 + ```bash 223 + cd apps/identity-wallet && pnpm tsc --noEmit 2>&1 | head -20 224 + ``` 225 + 226 + Expected: no TypeScript errors. If `pnpm` is not available, use `npx tsc --noEmit`. 227 + <!-- END_TASK_3 --> 228 + 229 + <!-- START_TASK_4 --> 230 + ### Task 4: Run full test suite, verify build, and commit 231 + 232 + **Verifies:** All Phase 1 ACs + MM-145.AC4.1 + MM-145.AC4.2 233 + 234 + **Files:** No changes — verification only. 235 + 236 + **Step 1: Run all Rust tests** 237 + 238 + ```bash 239 + cargo test -p identity-wallet -- --test-threads=1 2>&1 240 + ``` 241 + 242 + Expected: all tests pass, including the new `device_public_key_serializes_camel_case` test (8 device_key tests total now, plus existing lib.rs tests). 243 + 244 + **Step 2: Run clippy** 245 + 246 + ```bash 247 + cargo clippy -p identity-wallet -- -D warnings 248 + ``` 249 + 250 + Expected: no warnings. 251 + 252 + **Step 3: Verify iOS build compiles** 253 + 254 + ```bash 255 + cargo build -p identity-wallet --target aarch64-apple-ios 2>&1 256 + ``` 257 + 258 + Expected: compiles without errors. 259 + 260 + **Step 4: Manual simulator verification (AC4.2)** 261 + 262 + On the iOS Simulator (via `cargo tauri ios dev`): 263 + 1. Call `getOrCreateDeviceKey()` from a Svelte component — verify it resolves with `{ multibase: 'z...', keyId: 'did:key:z...' }` 264 + 2. Call `signWithDeviceKey(new Uint8Array([1,2,3]))` — verify it resolves with a `Uint8Array` of length 64 265 + 3. Call `signWithDeviceKey` before `getOrCreateDeviceKey` is ever called (fresh install) — verify it rejects with `{ code: 'KEY_NOT_FOUND' }` 266 + 267 + **Step 5: Commit** 268 + 269 + ```bash 270 + git add apps/identity-wallet/src-tauri/src/device_key.rs \ 271 + apps/identity-wallet/src-tauri/src/lib.rs \ 272 + apps/identity-wallet/src/lib/ipc.ts 273 + git commit -m "feat(ipc): expose get_or_create_device_key and sign_with_device_key Tauri commands" 274 + ``` 275 + <!-- END_TASK_4 --> 276 + 277 + <!-- END_SUBCOMPONENT_B -->
+32
docs/implementation-plans/2026-03-18-MM-145/test-requirements.md
··· 1 + # MM-145 Test Requirements 2 + 3 + ## Automated Tests 4 + 5 + | AC | Test Name | Type | File | 6 + |----|-----------|------|------| 7 + | MM-145.AC1.1 | `get_or_create_returns_valid_multibase` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 8 + | MM-145.AC1.2 | `get_or_create_is_idempotent` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 9 + | MM-145.AC1.3 | `key_id_has_did_key_prefix` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 10 + | MM-145.AC3.1 | `sign_returns_64_bytes` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 11 + | MM-145.AC3.2 | `sign_is_deterministic` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 12 + | MM-145.AC3.3 | `sign_before_generate_returns_key_not_found` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 13 + | MM-145.AC4.1 | `device_key_error_serializes_as_code` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 14 + | MM-145.AC4.1 | `device_public_key_serializes_camel_case` | unit | `apps/identity-wallet/src-tauri/src/device_key.rs` | 15 + | MM-145.AC5.1 | `create_account_uses_device_key_public_key` | unit | `apps/identity-wallet/src-tauri/src/lib.rs` | 16 + 17 + ## Human Verification 18 + 19 + | AC | What to Verify | How | 20 + |----|---------------|-----| 21 + | MM-145.AC1.4 | Key persists across app restarts (real Keychain round-trip) | Implicitly covered by `get_or_create_is_idempotent` on the simulator/macOS path (stateless function always reads from Keychain). Cross-process persistence on a real device is verified manually via AC2.1 below. | 22 + | MM-145.AC2.1 | Key retrieved after cold restart matches key from initial generation (SE tag persistence) | On a physical iOS device: (1) build and run the app via `cargo tauri ios dev`; (2) call `device_key::get_or_create()` and record the returned multibase string; (3) force-kill and relaunch the app (cold restart); (4) call `device_key::get_or_create()` again and verify the multibase string is identical. | 23 + | MM-145.AC2.2 | Private key bytes cannot be extracted from the Keychain (SE non-extractable guarantee) | On a physical iOS device: (1) `SecKey::new` with `Token::SecureEnclave` creates a non-extractable key by hardware design; (2) verify that calling `external_representation()` on the SE private key returns `None` (the SE rejects export). This is a design-level guarantee of Apple's Secure Enclave hardware and cannot be tested in the simulator or via `cargo test`. | 24 + | MM-145.AC4.2 | Frontend `ipc.ts` can call `getOrCreateDeviceKey()` and `signWithDeviceKey()` and receive correct TypeScript types | On the iOS Simulator via `cargo tauri ios dev`: (1) call `getOrCreateDeviceKey()` from a Svelte component and verify it resolves with `{ multibase: 'z...', keyId: 'did:key:z...' }`; (2) call `signWithDeviceKey(new Uint8Array([1,2,3]))` and verify it resolves with a `Uint8Array` of length 64; (3) call `signWithDeviceKey` before `getOrCreateDeviceKey` is ever called (fresh install) and verify it rejects with `{ code: 'KEY_NOT_FOUND' }`. | 25 + 26 + ## Notes 27 + 28 + - **Test isolation:** All unit tests share the macOS Keychain entry `"device-rotation-key-priv"` under service `"ezpds-identity-wallet"`. The `sign_before_generate_returns_key_not_found` test deletes this entry to simulate a fresh state. Tests must run single-threaded to prevent Keychain races. 29 + - **Run command:** `cargo test -p identity-wallet -- --test-threads=1` 30 + - **Platform requirements:** Automated tests run on macOS host via `cargo test`. The simulator/macOS software path is selected at compile time by `#[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))]`. The Secure Enclave path (`#[cfg(all(target_os = "ios", not(target_env = "sim")))]`) compiles only for `aarch64-apple-ios` and requires a physical iOS device for manual verification. 31 + - **Phase ordering:** Tests are introduced incrementally across phases. Phase 1 adds 7 tests in `device_key.rs`. Phase 3 adds 1 test in `lib.rs`. Phase 4 adds 1 test in `device_key.rs`. Total: 9 automated tests. 32 + - **No mock server:** `create_account` makes a real HTTP call to the relay, so AC5.1 is tested indirectly by verifying the `device_key::get_or_create()` contract (correct format, idempotent) rather than calling `create_account` directly. Compile-time verification ensures the wiring is correct (`cargo check` catches type mismatches).
+290
docs/implementation-plans/2026-03-20-MM-146/phase_01.md
··· 1 + # MM-146 DID Ceremony Implementation Plan 2 + 3 + **Goal:** Expose the relay's active signing key as a public `GET /v1/relay/keys` endpoint. 4 + 5 + **Architecture:** Single axum GET handler that queries `relay_signing_keys ORDER BY created_at DESC LIMIT 1`. Returns the most-recently-created key as `{ keyId, publicKey, algorithm }`, or 503 if no key is provisioned. No authentication required — this is a public endpoint. 6 + 7 + **Tech Stack:** Rust, axum, sqlx (SQLite), serde_json, Bruno 8 + 9 + **Scope:** Phase 1 of 4 from the MM-146 design plan. 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-146.AC1: GET /v1/relay/keys returns active signing key 20 + - **MM-146.AC1.1 Success:** Returns 200 with `{ keyId, publicKey, algorithm }` when a signing key is provisioned 21 + - **MM-146.AC1.2 Success:** Returns the most recently created key when multiple keys exist 22 + - **MM-146.AC1.3 Failure:** Returns 503 when no signing key is provisioned 23 + - **MM-146.AC1.4 Success:** Endpoint requires no authentication (public, no Bearer token needed) 24 + 25 + --- 26 + 27 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 28 + 29 + <!-- START_TASK_1 --> 30 + ### Task 1: Create get_relay_signing_key.rs handler 31 + 32 + **Files:** 33 + - Create: `crates/relay/src/routes/get_relay_signing_key.rs` 34 + 35 + **Implementation:** 36 + 37 + Create the file with the response struct and handler. The handler performs a single `SELECT ... ORDER BY created_at DESC LIMIT 1` query and returns 503 if no row exists. 38 + 39 + ```rust 40 + // pattern: Imperative Shell 41 + // 42 + // Gathers: DB pool (via AppState) 43 + // Processes: SELECT most recently created signing key 44 + // Returns: JSON { keyId, publicKey, algorithm } on success; 503 if no key provisioned 45 + 46 + use axum::{extract::State, response::Json}; 47 + use serde::Serialize; 48 + 49 + use common::{ApiError, ErrorCode}; 50 + 51 + use crate::app::AppState; 52 + 53 + // Response uses camelCase per JSON API convention (keyId, publicKey). 54 + #[derive(Serialize)] 55 + #[serde(rename_all = "camelCase")] 56 + pub struct GetRelaySigningKeyResponse { 57 + key_id: String, 58 + public_key: String, 59 + algorithm: String, 60 + } 61 + 62 + pub async fn get_relay_signing_key( 63 + State(state): State<AppState>, 64 + ) -> Result<Json<GetRelaySigningKeyResponse>, ApiError> { 65 + let row: Option<(String, String, String)> = sqlx::query_as( 66 + "SELECT id, public_key, algorithm \ 67 + FROM relay_signing_keys \ 68 + ORDER BY created_at DESC \ 69 + LIMIT 1", 70 + ) 71 + .fetch_optional(&state.db) 72 + .await 73 + .map_err(|e| { 74 + tracing::error!(error = %e, "failed to query relay signing key"); 75 + ApiError::new(ErrorCode::InternalError, "failed to query signing key") 76 + })?; 77 + 78 + let (id, public_key, algorithm) = row.ok_or_else(|| { 79 + ApiError::new(ErrorCode::ServiceUnavailable, "no signing key provisioned") 80 + })?; 81 + 82 + Ok(Json(GetRelaySigningKeyResponse { 83 + key_id: id, 84 + public_key, 85 + algorithm, 86 + })) 87 + } 88 + ``` 89 + 90 + **Note:** Do not add a `#[cfg(test)]` block yet — that comes in Task 3. 91 + <!-- END_TASK_1 --> 92 + 93 + <!-- START_TASK_2 --> 94 + ### Task 2: Wire module and route registration 95 + 96 + **Files:** 97 + - Modify: `crates/relay/src/routes/mod.rs` — add `pub mod get_relay_signing_key;` after line 7 (`pub mod create_signing_key;`) 98 + - Modify: `crates/relay/src/app.rs` — add import + update route registration on line 122 99 + 100 + **mod.rs change** — add one line after the `create_signing_key` module declaration: 101 + 102 + ```rust 103 + pub mod create_signing_key; 104 + pub mod get_relay_signing_key; // add this line 105 + ``` 106 + 107 + **app.rs changes:** 108 + 109 + Add a use import after line 21 (`use crate::routes::create_signing_key::create_signing_key;`): 110 + 111 + ```rust 112 + use crate::routes::get_relay_signing_key::get_relay_signing_key; 113 + ``` 114 + 115 + Update line 122 — change the relay keys route from POST-only to GET+POST: 116 + 117 + ```rust 118 + // Before: 119 + .route("/v1/relay/keys", post(create_signing_key)) 120 + 121 + // After: 122 + .route("/v1/relay/keys", get(get_relay_signing_key).post(create_signing_key)) 123 + ``` 124 + 125 + **Verification:** 126 + 127 + Run: `cargo build -p relay` 128 + Expected: Compiles without errors or warnings. 129 + <!-- END_TASK_2 --> 130 + 131 + <!-- START_TASK_3 --> 132 + ### Task 3: Integration tests 133 + 134 + **Verifies:** MM-146.AC1.1, MM-146.AC1.2, MM-146.AC1.3, MM-146.AC1.4 135 + 136 + **Files:** 137 + - Modify: `crates/relay/src/routes/get_relay_signing_key.rs` — append `#[cfg(test)] mod tests` block 138 + 139 + **Testing:** 140 + 141 + Tests must verify each AC listed above. All tests use `test_state()` from `crate::app` (in-memory SQLite DB). Add a `insert_test_key` helper and a `get_keys` request builder inside the test module. 142 + 143 + Append to `get_relay_signing_key.rs`: 144 + 145 + ```rust 146 + #[cfg(test)] 147 + mod tests { 148 + use axum::{ 149 + body::Body, 150 + http::{Request, StatusCode}, 151 + }; 152 + use tower::ServiceExt; 153 + 154 + use crate::app::{app, test_state}; 155 + 156 + /// Insert a signing key row directly into the test DB. 157 + /// `created_at` is an ISO 8601 UTC string, e.g. `"2026-01-01T00:00:00"`. 158 + /// 159 + /// `private_key_encrypted` is a NOT NULL column, but the GET handler never reads it, 160 + /// so any valid base64 value satisfies the constraint. The real format is 161 + /// base64(nonce(12) || ciphertext(32) || tag(16)) = 80 base64 chars. The 84-char 162 + /// placeholder below (60 zero-bytes base64-encoded + padding) is intentionally a 163 + /// dummy — replace with a correct 80-char value if a test ever needs to read 164 + /// private_key_encrypted back. 165 + async fn insert_test_key(db: &sqlx::SqlitePool, key_id: &str, created_at: &str) { 166 + sqlx::query( 167 + "INSERT INTO relay_signing_keys \ 168 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 169 + VALUES (?, 'p256', 'zTestPublicKey123', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==', ?)", 170 + ) 171 + .bind(key_id) 172 + .bind(created_at) 173 + .execute(db) 174 + .await 175 + .unwrap(); 176 + } 177 + 178 + /// Build a GET /v1/relay/keys request with no Authorization header (public endpoint). 179 + fn get_keys() -> Request<Body> { 180 + Request::builder() 181 + .method("GET") 182 + .uri("/v1/relay/keys") 183 + .body(Body::empty()) 184 + .unwrap() 185 + } 186 + 187 + // MM-146.AC1.3: Returns 503 when no signing key is provisioned. 188 + #[tokio::test] 189 + async fn get_relay_keys_returns_503_when_no_key_provisioned() { 190 + let response = app(test_state().await) 191 + .oneshot(get_keys()) 192 + .await 193 + .unwrap(); 194 + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); 195 + } 196 + 197 + // MM-146.AC1.1: Returns 200 with { keyId, publicKey, algorithm } when a key is provisioned. 198 + #[tokio::test] 199 + async fn get_relay_keys_returns_200_with_active_key() { 200 + let state = test_state().await; 201 + insert_test_key(&state.db, "did:key:zTestKey1", "2026-01-01T00:00:00").await; 202 + 203 + let response = app(state).oneshot(get_keys()).await.unwrap(); 204 + 205 + assert_eq!(response.status(), StatusCode::OK); 206 + let body = axum::body::to_bytes(response.into_body(), 4096) 207 + .await 208 + .unwrap(); 209 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 210 + assert_eq!(json["keyId"], "did:key:zTestKey1"); 211 + assert_eq!(json["algorithm"], "p256"); 212 + assert!(json["publicKey"].is_string(), "publicKey must be present"); 213 + } 214 + 215 + // MM-146.AC1.2: Returns the most recently created key when multiple keys exist. 216 + #[tokio::test] 217 + async fn get_relay_keys_returns_most_recently_created_key() { 218 + let state = test_state().await; 219 + insert_test_key(&state.db, "did:key:zOlderKey", "2026-01-01T00:00:00").await; 220 + insert_test_key(&state.db, "did:key:zNewerKey", "2026-01-02T00:00:00").await; 221 + 222 + let response = app(state).oneshot(get_keys()).await.unwrap(); 223 + 224 + let body = axum::body::to_bytes(response.into_body(), 4096) 225 + .await 226 + .unwrap(); 227 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 228 + assert_eq!( 229 + json["keyId"], "did:key:zNewerKey", 230 + "must return the key with the most recent created_at" 231 + ); 232 + } 233 + 234 + // MM-146.AC1.4: Endpoint requires no authentication. 235 + #[tokio::test] 236 + async fn get_relay_keys_requires_no_authentication() { 237 + // test_state() has no admin_token configured. 238 + // get_keys() sends no Authorization header. 239 + // If the endpoint incorrectly required auth, this would return 401 instead of 200. 240 + let state = test_state().await; 241 + insert_test_key(&state.db, "did:key:zPublicKey", "2026-01-01T00:00:00").await; 242 + 243 + let response = app(state).oneshot(get_keys()).await.unwrap(); 244 + assert_eq!(response.status(), StatusCode::OK); 245 + } 246 + } 247 + ``` 248 + 249 + **Verification:** 250 + 251 + Run: `cargo test -p relay get_relay` 252 + Expected: All 4 tests pass. 253 + <!-- END_TASK_3 --> 254 + 255 + <!-- START_TASK_4 --> 256 + ### Task 4: Add Bruno file and commit 257 + 258 + **Verifies:** None (documentation artifact) 259 + 260 + **Files:** 261 + - Create: `bruno/get_relay_keys.bru` 262 + 263 + **Implementation:** 264 + 265 + ``` 266 + meta { 267 + name: Get Relay Keys 268 + type: http 269 + seq: 11 270 + } 271 + 272 + get { 273 + url: {{baseUrl}}/v1/relay/keys 274 + body: none 275 + auth: none 276 + } 277 + ``` 278 + 279 + **Commit:** 280 + 281 + ```bash 282 + git add crates/relay/src/routes/get_relay_signing_key.rs \ 283 + crates/relay/src/routes/mod.rs \ 284 + crates/relay/src/app.rs \ 285 + bruno/get_relay_keys.bru 286 + git commit -m "feat(relay): add GET /v1/relay/keys endpoint to expose active signing key" 287 + ``` 288 + <!-- END_TASK_4 --> 289 + 290 + <!-- END_SUBCOMPONENT_A -->
+295
docs/implementation-plans/2026-03-20-MM-146/phase_02.md
··· 1 + # MM-146 DID Ceremony Implementation Plan 2 + 3 + **Goal:** Add `build_did_plc_genesis_op_with_external_signer` to the crypto crate so callers with non-extractable keys (Secure Enclave) can sign without exposing raw private key bytes. 4 + 5 + **Architecture:** Pure functional core addition to `crates/crypto/src/plc.rs`. The new function accepts an `FnOnce` signing callback instead of raw key bytes; the existing `build_did_plc_genesis_op` is refactored to a thin wrapper that constructs an inline callback from the private key bytes and delegates. No I/O, no new dependencies. 6 + 7 + **Tech Stack:** Rust, p256 (ECDSA), ciborium (DAG-CBOR), base64, sha2 8 + 9 + **Scope:** Phase 2 of 4 from the MM-146 design plan. 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-146.AC2: build_did_plc_genesis_op_with_external_signer produces valid genesis op 20 + - **MM-146.AC2.1 Success:** Callback receives CBOR-encoded unsigned op bytes; returned `PlcGenesisOp` passes `verify_genesis_op` 21 + - **MM-146.AC2.2 Failure:** Callback returning `Err` propagates as `CryptoError::PlcOperation` 22 + - **MM-146.AC2.3 Success:** Existing `build_did_plc_genesis_op` (now a wrapper) produces identical output to before (existing tests unchanged) 23 + 24 + --- 25 + 26 + <!-- START_SUBCOMPONENT_A (tasks 1-5) --> 27 + 28 + <!-- START_TASK_1 --> 29 + ### Task 1: Add build_did_plc_genesis_op_with_external_signer to plc.rs 30 + 31 + **Files:** 32 + - Modify: `crates/crypto/src/plc.rs` — insert new function before the existing `build_did_plc_genesis_op` (currently at line 161) 33 + 34 + **Implementation:** 35 + 36 + Insert the following block immediately after the `base32_lowercase` function (currently ending around line 159), and before the existing `build_did_plc_genesis_op` function: 37 + 38 + ```rust 39 + /// Build and sign a did:plc genesis operation using an external signing callback. 40 + /// 41 + /// This variant accepts a signing callback instead of raw private key bytes, enabling 42 + /// use with non-extractable keys such as Apple Secure Enclave keys. 43 + /// 44 + /// # Parameters 45 + /// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`. 46 + /// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`. 47 + /// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`. 48 + /// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`. 49 + /// - `sign`: A callback that receives the CBOR-encoded unsigned op bytes and must return the 50 + /// raw 64-byte r‖s P-256 ECDSA signature bytes (big-endian, low-S canonical). 51 + /// 52 + /// # Errors 53 + /// Returns `CryptoError::PlcOperation` if `sign` returns `Err`, or if any serialization step fails. 54 + pub fn build_did_plc_genesis_op_with_external_signer<F>( 55 + rotation_key: &DidKeyUri, 56 + signing_key: &DidKeyUri, 57 + handle: &str, 58 + service_endpoint: &str, 59 + sign: F, 60 + ) -> Result<PlcGenesisOp, CryptoError> 61 + where 62 + F: FnOnce(&[u8]) -> Result<Vec<u8>, CryptoError>, 63 + { 64 + // Step 1: Build the unsigned operation. 65 + let mut verification_methods = BTreeMap::new(); 66 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 67 + 68 + let mut services = BTreeMap::new(); 69 + services.insert( 70 + "atproto_pds".to_string(), 71 + PlcService { 72 + service_type: "AtprotoPersonalDataServer".to_string(), 73 + endpoint: service_endpoint.to_string(), 74 + }, 75 + ); 76 + 77 + let unsigned_op = UnsignedPlcOp { 78 + prev: None, 79 + op_type: "plc_operation".to_string(), 80 + services: services.clone(), 81 + also_known_as: vec![format!("at://{handle}")], 82 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 83 + verification_methods: verification_methods.clone(), 84 + }; 85 + 86 + // Step 2: CBOR-encode the unsigned operation. 87 + let mut unsigned_cbor = Vec::new(); 88 + into_writer(&unsigned_op, &mut unsigned_cbor) 89 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 90 + 91 + // Step 3: Call external signer with the CBOR bytes. 92 + // The callback must return raw 64-byte r‖s P-256 ECDSA signature bytes. 93 + let sig_bytes = sign(&unsigned_cbor)?; 94 + 95 + // Step 4: base64url-encode the signature (no padding). 96 + let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes); 97 + 98 + // Step 5: Build the signed operation (same fields + sig). 99 + let signed_op = SignedPlcOp { 100 + sig: sig_str, 101 + prev: None, 102 + op_type: "plc_operation".to_string(), 103 + services, 104 + also_known_as: vec![format!("at://{handle}")], 105 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 106 + verification_methods, 107 + }; 108 + 109 + // Step 6: CBOR-encode the signed operation. 110 + let mut signed_cbor = Vec::new(); 111 + into_writer(&signed_op, &mut signed_cbor) 112 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 113 + 114 + // Step 7: SHA-256 hash of the signed CBOR. 115 + let hash = Sha256::digest(&signed_cbor); 116 + 117 + // Step 8: base32-lowercase, take first 24 characters. 118 + let encoded = base32_lowercase()?.encode(hash.as_ref()); 119 + let did = format!("did:plc:{}", &encoded[..24]); 120 + 121 + // Step 9: JSON-serialize the signed operation. 122 + let signed_op_json = serde_json::to_string(&signed_op) 123 + .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?; 124 + 125 + Ok(PlcGenesisOp { 126 + did, 127 + signed_op_json, 128 + }) 129 + } 130 + ``` 131 + <!-- END_TASK_1 --> 132 + 133 + <!-- START_TASK_2 --> 134 + ### Task 2: Refactor build_did_plc_genesis_op into a thin wrapper 135 + 136 + **Files:** 137 + - Modify: `crates/crypto/src/plc.rs` — replace the body of `build_did_plc_genesis_op` (lines 161–239) with a delegation call to the new function 138 + 139 + **Implementation:** 140 + 141 + Replace the existing `build_did_plc_genesis_op` implementation (everything from the opening `{` on line 167 through the closing `}` on line 239) with this thin wrapper body: 142 + 143 + ```rust 144 + pub fn build_did_plc_genesis_op( 145 + rotation_key: &DidKeyUri, 146 + signing_key: &DidKeyUri, 147 + signing_private_key: &[u8; 32], 148 + handle: &str, 149 + service_endpoint: &str, 150 + ) -> Result<PlcGenesisOp, CryptoError> { 151 + let field_bytes: FieldBytes = (*signing_private_key).into(); 152 + let sk = SigningKey::from_bytes(&field_bytes) 153 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?; 154 + build_did_plc_genesis_op_with_external_signer( 155 + rotation_key, 156 + signing_key, 157 + handle, 158 + service_endpoint, 159 + |data| { 160 + let sig: Signature = Signer::sign(&sk, data); 161 + Ok(sig.to_bytes().to_vec()) 162 + }, 163 + ) 164 + } 165 + ``` 166 + 167 + The function signature (parameter names, types, doc comment) is unchanged. Only the body changes. 168 + 169 + **Verification:** 170 + 171 + Run: `cargo build -p crypto` 172 + Expected: Compiles without errors or warnings. 173 + <!-- END_TASK_2 --> 174 + 175 + <!-- START_TASK_3 --> 176 + ### Task 3: Update lib.rs to re-export the new function 177 + 178 + **Files:** 179 + - Modify: `crates/crypto/src/lib.rs` line 12 — add `build_did_plc_genesis_op_with_external_signer` to the plc re-export 180 + 181 + **Implementation:** 182 + 183 + Change line 12 from: 184 + 185 + ```rust 186 + pub use plc::{build_did_plc_genesis_op, verify_genesis_op, PlcGenesisOp, VerifiedGenesisOp}; 187 + ``` 188 + 189 + To: 190 + 191 + ```rust 192 + pub use plc::{ 193 + build_did_plc_genesis_op, build_did_plc_genesis_op_with_external_signer, verify_genesis_op, 194 + PlcGenesisOp, VerifiedGenesisOp, 195 + }; 196 + ``` 197 + 198 + **Verification:** 199 + 200 + Run: `cargo build -p crypto` 201 + Expected: Compiles without errors or warnings. 202 + <!-- END_TASK_3 --> 203 + 204 + <!-- START_TASK_4 --> 205 + ### Task 4: Add tests for the external signer function 206 + 207 + **Verifies:** MM-146.AC2.1, MM-146.AC2.2 208 + 209 + **Files:** 210 + - Modify: `crates/crypto/src/plc.rs` — append two new test functions to the existing `mod tests` block (before the closing `}` of the block, which ends at the final line of the file) 211 + 212 + **Testing:** 213 + 214 + Tests must verify each AC listed above. Both tests go inside the existing `#[cfg(test)] mod tests { use super::*; ... }` block, alongside the existing tests. Do not create a new test module. 215 + 216 + Append these two test functions **before the final closing `}` of the `mod tests` block** — i.e., the very last `}` in the file. The existing block ends with `}` on the last line; insert the new functions just above it: 217 + 218 + ```rust 219 + // MM-146.AC2.1: Callback receives CBOR bytes; returned PlcGenesisOp passes verify_genesis_op. 220 + #[test] 221 + fn external_signer_callback_produces_valid_genesis_op() { 222 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 223 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 224 + let private_key_bytes: [u8; 32] = *signing_kp.private_key_bytes; 225 + 226 + // Simulate SE: the key is available for signing but bytes are not "exposed" to the caller. 227 + let field_bytes: FieldBytes = private_key_bytes.into(); 228 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid signing key"); 229 + 230 + let result = build_did_plc_genesis_op_with_external_signer( 231 + &rotation_kp.key_id, 232 + &signing_kp.key_id, 233 + "alice.example.com", 234 + "https://relay.example.com", 235 + |data| { 236 + let sig: Signature = Signer::sign(&sk, data); 237 + Ok(sig.to_bytes().to_vec()) 238 + }, 239 + ) 240 + .expect("external signer should succeed"); 241 + 242 + // The resulting op must pass verify_genesis_op with the rotation key. 243 + let verified = verify_genesis_op(&result.signed_op_json, &rotation_kp.key_id) 244 + .expect("signed op must be verifiable with rotation key"); 245 + assert_eq!( 246 + verified.did, result.did, 247 + "verified DID must match the DID returned by the builder" 248 + ); 249 + } 250 + 251 + // MM-146.AC2.2: Callback returning Err propagates as CryptoError::PlcOperation. 252 + #[test] 253 + fn external_signer_callback_error_propagates_as_plc_operation() { 254 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 255 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 256 + 257 + let result = build_did_plc_genesis_op_with_external_signer( 258 + &rotation_kp.key_id, 259 + &signing_kp.key_id, 260 + "alice.example.com", 261 + "https://relay.example.com", 262 + |_data| Err(CryptoError::PlcOperation("SE signing failed".to_string())), 263 + ); 264 + 265 + assert!(result.is_err(), "must return error when callback fails"); 266 + match result.unwrap_err() { 267 + CryptoError::PlcOperation(msg) => { 268 + assert!( 269 + msg.contains("SE signing failed"), 270 + "error message must propagate from callback, got: {msg}" 271 + ); 272 + } 273 + other => panic!("expected CryptoError::PlcOperation, got: {other:?}"), 274 + } 275 + } 276 + ``` 277 + 278 + **Verification:** 279 + 280 + Run: `cargo test -p crypto` 281 + Expected: All existing tests still pass (MM-146.AC2.3 confirmed) + 2 new tests pass. 282 + <!-- END_TASK_4 --> 283 + 284 + <!-- START_TASK_5 --> 285 + ### Task 5: Commit 286 + 287 + **Files:** All changes to `crates/crypto/src/plc.rs` and `crates/crypto/src/lib.rs` 288 + 289 + ```bash 290 + git add crates/crypto/src/plc.rs crates/crypto/src/lib.rs 291 + git commit -m "feat(crypto): add build_did_plc_genesis_op_with_external_signer for SE-backed signing" 292 + ``` 293 + <!-- END_TASK_5 --> 294 + 295 + <!-- END_SUBCOMPONENT_A -->
+391
docs/implementation-plans/2026-03-20-MM-146/phase_03.md
··· 1 + # MM-146 DID Ceremony Implementation Plan 2 + 3 + **Goal:** Implement the `perform_did_ceremony` Tauri command that orchestrates the full 7-step DID ceremony: get device key, fetch relay signing key, build signed genesis op, retrieve pending session token, POST to relay, persist DID and new session token, return result. 4 + 5 + **Architecture:** Imperative Shell in `src-tauri/src/lib.rs` + HTTP client extension in `src-tauri/src/http.rs`. The crypto crate (Functional Core, Phase 2) is wired in via the `sign` callback. All I/O (Keychain, network) is in the Tauri command; no I/O in the crypto crate. 6 + 7 + **Tech Stack:** Rust, Tauri v2, reqwest, crypto crate (build_did_plc_genesis_op_with_external_signer), keychain module, device_key module 8 + 9 + **Scope:** Phase 3 of 4 from the MM-146 design plan. 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-146.AC3: perform_did_ceremony completes the full ceremony 20 + - **MM-146.AC3.1 Success:** Given a valid pending session token and provisioned relay key, returns `DIDCeremonyResult { did }` with a valid `did:plc` identifier 21 + - **MM-146.AC3.2 Success:** Keychain `"session-token"` is overwritten with the full session token from `POST /v1/dids` response 22 + - **MM-146.AC3.3 Success:** Keychain `"did"` is populated with the resulting DID 23 + - **MM-146.AC3.4 Failure:** Returns `DIDCeremonyError::NoRelaySigningKey` (serializes as `{ code: "NO_RELAY_SIGNING_KEY" }`) when relay has no key 24 + - **MM-146.AC3.5 Failure:** Returns `DIDCeremonyError::RelayKeyFetchFailed` when `GET /v1/relay/keys` is unreachable 25 + - **MM-146.AC3.6 Failure:** Returns `DIDCeremonyError::SigningFailed` when SE signing fails 26 + - **MM-146.AC3.7 Failure:** Returns `DIDCeremonyError::DidCreationFailed` when `POST /v1/dids` returns non-2xx 27 + 28 + --- 29 + 30 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 31 + 32 + <!-- START_TASK_1 --> 33 + ### Task 1: Extend RelayClient with get() and post_with_bearer() methods and base_url() accessor 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src-tauri/src/http.rs` 37 + 38 + **Implementation:** 39 + 40 + Add three new items to the `RelayClient` impl block, after the existing `post()` method: 41 + 42 + ```rust 43 + /// GET `path` (relative, e.g. `"/v1/relay/keys"`). 44 + /// 45 + /// Returns the raw `Response` so callers can inspect the status code 46 + /// before attempting to deserialize the body. 47 + pub async fn get(&self, path: &str) -> reqwest::Result<Response> { 48 + let url = format!("{}{}", self.base_url, path); 49 + self.client.get(&url).send().await 50 + } 51 + 52 + /// POST JSON to `path` with a Bearer token in the Authorization header. 53 + /// 54 + /// Used for authenticated relay endpoints (e.g. `POST /v1/dids` which 55 + /// requires the pending session token). 56 + pub async fn post_with_bearer<T: Serialize>( 57 + &self, 58 + path: &str, 59 + body: &T, 60 + bearer_token: &str, 61 + ) -> reqwest::Result<Response> { 62 + let url = format!("{}{}", self.base_url, path); 63 + self.client 64 + .post(&url) 65 + .bearer_auth(bearer_token) 66 + .json(body) 67 + .send() 68 + .await 69 + } 70 + 71 + /// Returns the compile-time base URL for this relay client instance. 72 + /// 73 + /// Used as the `service_endpoint` parameter in DID ceremony genesis op construction. 74 + pub const fn base_url() -> &'static str { 75 + RELAY_BASE_URL 76 + } 77 + ``` 78 + 79 + **Verification:** 80 + 81 + Run: `cargo build -p identity-wallet` 82 + Expected: Compiles without errors or warnings. 83 + <!-- END_TASK_1 --> 84 + 85 + <!-- END_SUBCOMPONENT_A --> 86 + 87 + <!-- START_SUBCOMPONENT_B (tasks 2-4) --> 88 + 89 + <!-- START_TASK_2 --> 90 + ### Task 2: Add types and the perform_did_ceremony command to lib.rs 91 + 92 + **Verifies:** MM-146.AC3.1, MM-146.AC3.2, MM-146.AC3.3, MM-146.AC3.4, MM-146.AC3.5, MM-146.AC3.6, MM-146.AC3.7 93 + 94 + **Files:** 95 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 96 + 97 + **Implementation:** 98 + 99 + **Step 1:** Add imports after the existing `use serde::{Deserialize, Serialize};` import block at the top: 100 + 101 + ```rust 102 + use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri}; 103 + ``` 104 + 105 + Also add `tracing` to `apps/identity-wallet/src-tauri/Cargo.toml` if it is not already listed as a dependency (check with `cargo build -p identity-wallet` after adding — it compiles if present, or add `tracing = { workspace = true }` if the macro is not found): 106 + 107 + ```toml 108 + tracing = { workspace = true } 109 + ``` 110 + 111 + **Step 2:** Add the relay API types after the existing `CreateMobileAccountResponse` struct (around line 33), before the `RelayErrorEnvelope` struct: 112 + 113 + ```rust 114 + /// Response from GET /v1/relay/keys — the relay's active signing key. 115 + #[derive(Deserialize)] 116 + #[serde(rename_all = "camelCase")] 117 + struct RelaySigningKey { 118 + key_id: String, 119 + public_key: String, 120 + algorithm: String, 121 + } 122 + 123 + /// Request body for POST /v1/dids — submit the signed genesis op for DID promotion. 124 + #[derive(Serialize)] 125 + #[serde(rename_all = "camelCase")] 126 + struct CreateDidRequest { 127 + rotation_key_public: String, 128 + signed_creation_op: String, 129 + } 130 + 131 + /// Response from POST /v1/dids — the promoted DID and upgraded session token. 132 + #[derive(Deserialize)] 133 + #[serde(rename_all = "camelCase")] 134 + struct CreateDidResponse { 135 + did: String, 136 + session_token: String, 137 + } 138 + ``` 139 + 140 + **Step 3:** Add the IPC result and error types in the IPC types section (after `CreateAccountError`): 141 + 142 + ```rust 143 + /// Successful result returned to the Svelte frontend after DID ceremony completes. 144 + #[derive(Serialize)] 145 + #[serde(rename_all = "camelCase")] 146 + pub struct DIDCeremonyResult { 147 + pub did: String, 148 + } 149 + 150 + /// Typed error returned to the Svelte frontend as a rejected Promise. 151 + /// 152 + /// Serializes as `{ "code": "NO_RELAY_SIGNING_KEY" }` (SCREAMING_SNAKE_CASE) so 153 + /// the TypeScript catch block can switch on `error.code`. 154 + #[derive(Debug, Serialize, thiserror::Error)] 155 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 156 + pub enum DIDCeremonyError { 157 + #[error("device key not found; call get_or_create before ceremony")] 158 + KeyNotFound, 159 + #[error("failed to fetch relay signing key")] 160 + RelayKeyFetchFailed, 161 + #[error("relay has no signing key provisioned")] 162 + NoRelaySigningKey, 163 + #[error("device signing failed")] 164 + SigningFailed, 165 + #[error("DID creation request failed")] 166 + DidCreationFailed, 167 + #[error("keychain operation failed")] 168 + KeychainError, 169 + #[error("network error: {message}")] 170 + NetworkError { message: String }, 171 + } 172 + ``` 173 + 174 + **Step 4:** Add the `perform_did_ceremony` command after the `sign_with_device_key` command (before the `#[cfg_attr(mobile, tauri::mobile_entry_point)]` pub fn run()): 175 + 176 + ```rust 177 + #[tauri::command] 178 + async fn perform_did_ceremony(handle: String) -> Result<DIDCeremonyResult, DIDCeremonyError> { 179 + // Step 1: Get or create the device's P-256 key (serves as rotation key). 180 + let device_key = device_key::get_or_create().map_err(|e| { 181 + tracing::warn!(error = %e, "device key creation failed during DID ceremony"); 182 + DIDCeremonyError::KeyNotFound 183 + })?; 184 + 185 + // Step 2: Fetch the relay's active signing key (public, no auth required). 186 + let resp = RELAY_CLIENT 187 + .get("/v1/relay/keys") 188 + .await 189 + .map_err(|e| DIDCeremonyError::NetworkError { 190 + message: e.to_string(), 191 + })?; 192 + 193 + if resp.status().as_u16() == 503 { 194 + return Err(DIDCeremonyError::NoRelaySigningKey); 195 + } 196 + if !resp.status().is_success() { 197 + return Err(DIDCeremonyError::RelayKeyFetchFailed); 198 + } 199 + 200 + let relay_key: RelaySigningKey = resp 201 + .json() 202 + .await 203 + .map_err(|e| { 204 + tracing::warn!(error = %e, "failed to deserialize relay signing key response"); 205 + DIDCeremonyError::RelayKeyFetchFailed 206 + })?; 207 + 208 + // Step 3: Build signed genesis op — device key as rotation key, relay key as signing key. 209 + // The sign callback calls device_key::sign() so the private key never leaves the SE. 210 + let rotation_key = DidKeyUri(device_key.key_id.clone()); 211 + let signing_key = DidKeyUri(relay_key.key_id.clone()); 212 + 213 + let genesis_op = build_did_plc_genesis_op_with_external_signer( 214 + &rotation_key, 215 + &signing_key, 216 + &handle, 217 + http::RelayClient::base_url(), 218 + |data| { 219 + device_key::sign(data) 220 + .map_err(|e| CryptoError::PlcOperation(format!("device signing failed: {e}"))) 221 + }, 222 + ) 223 + .map_err(|e| { 224 + tracing::warn!(error = %e, "genesis op signing failed during DID ceremony"); 225 + DIDCeremonyError::SigningFailed 226 + })?; 227 + 228 + // Step 4: Retrieve the pending session token from Keychain. 229 + let token_bytes = keychain::get_item("session-token").map_err(|e| { 230 + tracing::warn!(error = %e, "failed to retrieve session-token from keychain"); 231 + DIDCeremonyError::KeychainError 232 + })?; 233 + let pending_token = String::from_utf8(token_bytes).map_err(|e| { 234 + tracing::warn!(error = %e, "session-token bytes are not valid UTF-8"); 235 + DIDCeremonyError::KeychainError 236 + })?; 237 + 238 + // Step 5: POST the signed genesis op to the relay to promote the account to a full DID. 239 + let create_did_req = CreateDidRequest { 240 + rotation_key_public: device_key.multibase, 241 + signed_creation_op: genesis_op.signed_op_json, 242 + }; 243 + 244 + let resp = RELAY_CLIENT 245 + .post_with_bearer("/v1/dids", &create_did_req, &pending_token) 246 + .await 247 + .map_err(|e| DIDCeremonyError::NetworkError { 248 + message: e.to_string(), 249 + })?; 250 + 251 + if !resp.status().is_success() { 252 + return Err(DIDCeremonyError::DidCreationFailed); 253 + } 254 + 255 + let create_did_resp: CreateDidResponse = resp 256 + .json() 257 + .await 258 + .map_err(|e| { 259 + tracing::warn!(error = %e, "failed to deserialize POST /v1/dids response"); 260 + DIDCeremonyError::DidCreationFailed 261 + })?; 262 + 263 + // Step 6: Overwrite session-token with the upgraded full session token. 264 + keychain::store_item( 265 + "session-token", 266 + create_did_resp.session_token.as_bytes(), 267 + ) 268 + .map_err(|e| { 269 + tracing::warn!(error = %e, "failed to persist upgraded session-token to keychain"); 270 + DIDCeremonyError::KeychainError 271 + })?; 272 + 273 + // Step 7: Persist the DID for use in subsequent app sessions. 274 + keychain::store_item("did", create_did_resp.did.as_bytes()).map_err(|e| { 275 + tracing::warn!(error = %e, "failed to persist DID to keychain"); 276 + DIDCeremonyError::KeychainError 277 + })?; 278 + 279 + Ok(DIDCeremonyResult { 280 + did: create_did_resp.did, 281 + }) 282 + } 283 + ``` 284 + 285 + **Step 5:** Register `perform_did_ceremony` in `tauri::generate_handler![]` (around line 194-198): 286 + 287 + ```rust 288 + .invoke_handler(tauri::generate_handler![ 289 + create_account, 290 + get_or_create_device_key, 291 + sign_with_device_key, 292 + perform_did_ceremony, 293 + ]) 294 + ``` 295 + 296 + **Verification:** 297 + 298 + Run: `cargo build -p identity-wallet` 299 + Expected: Compiles without errors or warnings. 300 + <!-- END_TASK_2 --> 301 + 302 + <!-- START_TASK_3 --> 303 + ### Task 3: Unit tests for DIDCeremonyResult and DIDCeremonyError serialization 304 + 305 + **Verifies:** MM-146.AC3.4, MM-146.AC3.5, MM-146.AC3.6, MM-146.AC3.7 (via serde serialization contracts) 306 + 307 + **Files:** 308 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` — add to the existing `#[cfg(test)] mod tests` block 309 + 310 + **Testing:** 311 + 312 + Tests must verify the serde serialization contracts that the TypeScript frontend depends on. Append these test functions to the existing `mod tests` block in `lib.rs`: 313 + 314 + ```rust 315 + // -- DIDCeremonyResult serialization -- 316 + #[test] 317 + fn did_ceremony_result_serializes_did_in_camel_case() { 318 + let result = DIDCeremonyResult { 319 + did: "did:plc:abcdefghijklmnopqrstuvwx".into(), 320 + }; 321 + let json = serde_json::to_value(&result).unwrap(); 322 + assert_eq!(json["did"], "did:plc:abcdefghijklmnopqrstuvwx"); 323 + } 324 + 325 + // -- DIDCeremonyError serialization (one test per variant) -- 326 + #[test] 327 + fn did_ceremony_error_key_not_found_serializes_correctly() { 328 + let json = serde_json::to_value(&DIDCeremonyError::KeyNotFound).unwrap(); 329 + assert_eq!(json["code"], "KEY_NOT_FOUND"); 330 + } 331 + 332 + #[test] 333 + fn did_ceremony_error_relay_key_fetch_failed_serializes_correctly() { 334 + let json = serde_json::to_value(&DIDCeremonyError::RelayKeyFetchFailed).unwrap(); 335 + assert_eq!(json["code"], "RELAY_KEY_FETCH_FAILED"); 336 + } 337 + 338 + #[test] 339 + fn did_ceremony_error_no_relay_signing_key_serializes_correctly() { 340 + let json = serde_json::to_value(&DIDCeremonyError::NoRelaySigningKey).unwrap(); 341 + assert_eq!(json["code"], "NO_RELAY_SIGNING_KEY"); 342 + } 343 + 344 + #[test] 345 + fn did_ceremony_error_signing_failed_serializes_correctly() { 346 + let json = serde_json::to_value(&DIDCeremonyError::SigningFailed).unwrap(); 347 + assert_eq!(json["code"], "SIGNING_FAILED"); 348 + } 349 + 350 + #[test] 351 + fn did_ceremony_error_did_creation_failed_serializes_correctly() { 352 + let json = serde_json::to_value(&DIDCeremonyError::DidCreationFailed).unwrap(); 353 + assert_eq!(json["code"], "DID_CREATION_FAILED"); 354 + } 355 + 356 + #[test] 357 + fn did_ceremony_error_keychain_error_serializes_correctly() { 358 + let json = serde_json::to_value(&DIDCeremonyError::KeychainError).unwrap(); 359 + assert_eq!(json["code"], "KEYCHAIN_ERROR"); 360 + } 361 + 362 + #[test] 363 + fn did_ceremony_error_network_error_serializes_with_message() { 364 + let err = DIDCeremonyError::NetworkError { 365 + message: "Connection refused".into(), 366 + }; 367 + let json = serde_json::to_value(&err).unwrap(); 368 + assert_eq!(json["code"], "NETWORK_ERROR"); 369 + assert_eq!(json["message"], "Connection refused"); 370 + } 371 + ``` 372 + 373 + **Note on behavioral test coverage:** The `perform_did_ceremony` command orchestrates Keychain, Secure Enclave, and HTTP — none of which can be meaningfully mocked in a `cargo test` unit test environment on macOS/iOS. The 8 serde tests here cover the TypeScript-facing serialization contracts. Full behavioral coverage (ceremony runs end-to-end, keychain values are persisted, errors surface correctly) is verified via iOS Simulator manual testing described in the test-requirements document. 374 + 375 + **Verification:** 376 + 377 + Run: `cargo test -p identity-wallet` 378 + Expected: All existing tests pass + 8 new serialization tests pass. 379 + <!-- END_TASK_3 --> 380 + 381 + <!-- START_TASK_4 --> 382 + ### Task 4: Commit 383 + 384 + ```bash 385 + git add apps/identity-wallet/src-tauri/src/http.rs \ 386 + apps/identity-wallet/src-tauri/src/lib.rs 387 + git commit -m "feat(identity-wallet): add perform_did_ceremony Tauri command and relay client extensions" 388 + ``` 389 + <!-- END_TASK_4 --> 390 + 391 + <!-- END_SUBCOMPONENT_B -->
+403
docs/implementation-plans/2026-03-20-MM-146/phase_04.md
··· 1 + # MM-146 DID Ceremony Implementation Plan 2 + 3 + **Goal:** Wire the `perform_did_ceremony` Tauri command into the TypeScript IPC layer and implement the loading, success, and retry UI screens. 4 + 5 + **Architecture:** Four changes: (1) add types + `performDIDCeremony` to `ipc.ts`, (2) create `DIDCeremonyScreen.svelte` that auto-starts the ceremony and handles retry, (3) create `DIDSuccessScreen.svelte` showing the truncated DID, (4) update `+page.svelte` to wire up both new screens and add `'did_success'` + `'shamir_backup'` steps. 6 + 7 + **Tech Stack:** TypeScript, Svelte 5 (runes: `$state`, `$derived`, `$props`, `onMount`), `@tauri-apps/api/core` invoke 8 + 9 + **Scope:** Phase 4 of 4 from the MM-146 design plan. 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-146.AC4: DID ceremony UI 20 + - **MM-146.AC4.1 Success:** App shows loading screen with status text while ceremony is in flight 21 + - **MM-146.AC4.2 Success:** On success, transitions to success screen showing truncated DID and a "Continue" button 22 + - **MM-146.AC4.3 Failure:** On failure, shows inline error message and a Retry button (does not rewind to previous screen) 23 + - **MM-146.AC4.4 Success:** Retry button re-invokes the ceremony from the beginning 24 + - **MM-146.AC4.5 Success:** "Continue" button transitions to `shamir_backup` placeholder step 25 + 26 + --- 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-5) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Add DID ceremony types and performDIDCeremony to ipc.ts 32 + 33 + **Verifies:** MM-146.AC4.1 (prerequisite IPC layer) 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` — append at the end of the file 37 + 38 + **Implementation:** 39 + 40 + Append the following block to the end of `ipc.ts`: 41 + 42 + ```typescript 43 + // ── perform_did_ceremony ───────────────────────────────────────────────────── 44 + 45 + /** 46 + * Successful result from the `perform_did_ceremony` Rust command. 47 + * This is a pure data shape returned on success. 48 + */ 49 + export type DIDCeremonyResult = { 50 + did: string; 51 + }; 52 + 53 + /** 54 + * Error returned by the `perform_did_ceremony` Rust command. 55 + * 56 + * Serialized as `{ code: "NO_RELAY_SIGNING_KEY" }` etc. by the Rust backend. 57 + * The `message` field is present only on the NETWORK_ERROR variant. 58 + * This is a pure data shape used for error handling. 59 + */ 60 + export type DIDCeremonyError = { 61 + code: 62 + | 'KEY_NOT_FOUND' 63 + | 'RELAY_KEY_FETCH_FAILED' 64 + | 'NO_RELAY_SIGNING_KEY' 65 + | 'SIGNING_FAILED' 66 + | 'DID_CREATION_FAILED' 67 + | 'KEYCHAIN_ERROR' 68 + | 'NETWORK_ERROR'; 69 + message?: string; 70 + }; 71 + 72 + /** 73 + * Perform the DID ceremony: fetch relay key, build signed genesis op, post to relay, 74 + * persist DID and upgraded session token in Keychain. 75 + * 76 + * On success, the DID and new session token are stored in Keychain by the Rust backend. 77 + * On failure, the Promise rejects with a `DIDCeremonyError`. 78 + */ 79 + export const performDIDCeremony = (handle: string): Promise<DIDCeremonyResult> => 80 + invoke('perform_did_ceremony', { handle }); 81 + ``` 82 + 83 + **Verification:** 84 + 85 + Run from `apps/identity-wallet/`: 86 + ```bash 87 + pnpm check 88 + ``` 89 + Expected: TypeScript type-check passes with no errors. 90 + <!-- END_TASK_1 --> 91 + 92 + <!-- START_TASK_2 --> 93 + ### Task 2: Create DIDCeremonyScreen.svelte 94 + 95 + **Verifies:** MM-146.AC4.1, MM-146.AC4.3, MM-146.AC4.4 96 + 97 + **Files:** 98 + - Create: `apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte` 99 + 100 + **Implementation:** 101 + 102 + ```svelte 103 + <script lang="ts"> 104 + import { onMount } from 'svelte'; 105 + import LoadingScreen from './LoadingScreen.svelte'; 106 + import { performDIDCeremony, type DIDCeremonyError } from '$lib/ipc'; 107 + 108 + let { 109 + handle, 110 + onsuccess, 111 + }: { 112 + handle: string; 113 + onsuccess: (did: string) => void; 114 + } = $props(); 115 + 116 + let loading = $state(true); 117 + let error = $state<DIDCeremonyError | null>(null); 118 + 119 + async function runCeremony() { 120 + loading = true; 121 + error = null; 122 + try { 123 + const result = await performDIDCeremony(handle); 124 + loading = false; 125 + onsuccess(result.did); 126 + } catch (raw: unknown) { 127 + loading = false; 128 + if ( 129 + typeof raw === 'object' && 130 + raw !== null && 131 + 'code' in raw && 132 + typeof (raw as DIDCeremonyError).code === 'string' 133 + ) { 134 + error = raw as DIDCeremonyError; 135 + } else { 136 + error = { code: 'NETWORK_ERROR', message: 'An unexpected error occurred.' }; 137 + } 138 + } 139 + } 140 + 141 + function errorMessage(err: DIDCeremonyError): string { 142 + switch (err.code) { 143 + case 'NO_RELAY_SIGNING_KEY': 144 + return "The relay hasn't been configured yet. Please try again later."; 145 + case 'RELAY_KEY_FETCH_FAILED': 146 + case 'NETWORK_ERROR': 147 + return "Couldn't reach the server. Check your connection."; 148 + case 'SIGNING_FAILED': 149 + return 'Device signing failed. Please try again.'; 150 + case 'DID_CREATION_FAILED': 151 + return "Couldn't create your identity. Please try again."; 152 + case 'KEYCHAIN_ERROR': 153 + return "Couldn't save to your device. Please try again."; 154 + case 'KEY_NOT_FOUND': 155 + default: 156 + return 'Something went wrong. Please try again.'; 157 + } 158 + } 159 + 160 + onMount(() => runCeremony()); 161 + </script> 162 + 163 + {#if loading} 164 + <LoadingScreen statusText="Setting up your identity…" /> 165 + {:else if error} 166 + <div class="screen"> 167 + <p class="error-text">{errorMessage(error)}</p> 168 + <button class="retry" onclick={() => runCeremony()}>Retry</button> 169 + </div> 170 + {/if} 171 + 172 + <style> 173 + .screen { 174 + display: flex; 175 + flex-direction: column; 176 + align-items: center; 177 + justify-content: center; 178 + height: 100%; 179 + padding: 2rem; 180 + gap: 1.5rem; 181 + text-align: center; 182 + } 183 + 184 + .error-text { 185 + font-size: 1rem; 186 + color: #ef4444; 187 + margin: 0; 188 + } 189 + 190 + .retry { 191 + width: 100%; 192 + max-width: 320px; 193 + padding: 1rem; 194 + background: #007aff; 195 + color: #fff; 196 + border: none; 197 + border-radius: 12px; 198 + font-size: 1.1rem; 199 + font-weight: 600; 200 + cursor: pointer; 201 + } 202 + </style> 203 + ``` 204 + <!-- END_TASK_2 --> 205 + 206 + <!-- START_TASK_3 --> 207 + ### Task 3: Create DIDSuccessScreen.svelte 208 + 209 + **Verifies:** MM-146.AC4.2, MM-146.AC4.5 210 + 211 + **Files:** 212 + - Create: `apps/identity-wallet/src/lib/components/onboarding/DIDSuccessScreen.svelte` 213 + 214 + **Implementation:** 215 + 216 + The DID format is `did:plc:` (8 chars) + 24 lowercase base32 chars = 32 total. Truncate for display: show the `did:plc:` prefix plus first 5 suffix chars + `…` + last 4 suffix chars. 217 + 218 + ```svelte 219 + <script lang="ts"> 220 + let { 221 + did, 222 + oncontinue, 223 + }: { 224 + did: string; 225 + oncontinue: () => void; 226 + } = $props(); 227 + 228 + // Truncate the DID suffix for display on a narrow mobile screen. 229 + // "did:plc:abcdefghijklmnopqrstuvwx" → "did:plc:abcde…uvwx" 230 + let displayDid = $derived( 231 + did.startsWith('did:plc:') && did.length > 20 232 + ? `did:plc:${did.slice(8, 13)}…${did.slice(-4)}` 233 + : did 234 + ); 235 + </script> 236 + 237 + <div class="screen"> 238 + <div class="success-icon" aria-hidden="true">✓</div> 239 + <h2>Identity Created!</h2> 240 + <p class="label">Your decentralized identifier</p> 241 + <code class="did">{displayDid}</code> 242 + <button class="cta" onclick={oncontinue}>Continue</button> 243 + </div> 244 + 245 + <style> 246 + .screen { 247 + display: flex; 248 + flex-direction: column; 249 + align-items: center; 250 + justify-content: center; 251 + height: 100%; 252 + padding: 2rem; 253 + gap: 1.25rem; 254 + text-align: center; 255 + } 256 + 257 + .success-icon { 258 + width: 64px; 259 + height: 64px; 260 + background: #007aff; 261 + color: #fff; 262 + border-radius: 50%; 263 + display: flex; 264 + align-items: center; 265 + justify-content: center; 266 + font-size: 2rem; 267 + font-weight: 700; 268 + } 269 + 270 + h2 { 271 + font-size: 1.5rem; 272 + font-weight: 700; 273 + margin: 0; 274 + } 275 + 276 + .label { 277 + font-size: 0.875rem; 278 + color: #6b7280; 279 + margin: 0; 280 + } 281 + 282 + .did { 283 + font-family: monospace; 284 + font-size: 0.9rem; 285 + background: #f3f4f6; 286 + padding: 0.5rem 1rem; 287 + border-radius: 8px; 288 + word-break: break-all; 289 + } 290 + 291 + .cta { 292 + width: 100%; 293 + max-width: 320px; 294 + padding: 1rem; 295 + background: #007aff; 296 + color: #fff; 297 + border: none; 298 + border-radius: 12px; 299 + font-size: 1.1rem; 300 + font-weight: 600; 301 + cursor: pointer; 302 + } 303 + </style> 304 + ``` 305 + <!-- END_TASK_3 --> 306 + 307 + <!-- START_TASK_4 --> 308 + ### Task 4: Update +page.svelte to wire up new screens 309 + 310 + **Verifies:** MM-146.AC4.2, MM-146.AC4.5 311 + 312 + **Files:** 313 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 314 + 315 + **Implementation:** 316 + 317 + **Step 1:** Add two new imports after the existing `LoadingScreen` import (line 6): 318 + 319 + ```svelte 320 + import DIDCeremonyScreen from '$lib/components/onboarding/DIDCeremonyScreen.svelte'; 321 + import DIDSuccessScreen from '$lib/components/onboarding/DIDSuccessScreen.svelte'; 322 + ``` 323 + 324 + **Step 2:** Expand `OnboardingStep` to include the two new steps (currently lines 19-25): 325 + 326 + ```typescript 327 + type OnboardingStep = 328 + | 'welcome' 329 + | 'claim_code' 330 + | 'email' 331 + | 'handle' 332 + | 'loading' 333 + | 'did_ceremony' 334 + | 'did_success' 335 + | 'shamir_backup'; 336 + ``` 337 + 338 + **Step 3:** Add `did` field to `form` (currently line 30): 339 + 340 + ```typescript 341 + let form = $state({ claimCode: '', email: '', handle: '', did: '' }); 342 + ``` 343 + 344 + **Step 4:** Replace the `did_ceremony` placeholder block (currently lines 137-141) with the wired-up screens: 345 + 346 + Replace: 347 + ```svelte 348 + {:else if step === 'did_ceremony'} 349 + <div class="placeholder"> 350 + <h2>Account Created!</h2> 351 + <p>DID ceremony coming soon…</p> 352 + </div> 353 + ``` 354 + 355 + With: 356 + ```svelte 357 + {:else if step === 'did_ceremony'} 358 + <DIDCeremonyScreen 359 + handle={form.handle} 360 + onsuccess={(did) => { form.did = did; step = 'did_success'; }} 361 + /> 362 + {:else if step === 'did_success'} 363 + <DIDSuccessScreen 364 + did={form.did} 365 + oncontinue={() => { step = 'shamir_backup'; }} 366 + /> 367 + {:else if step === 'shamir_backup'} 368 + <div class="placeholder"> 369 + <h2>Backup</h2> 370 + <p>Shamir backup coming soon…</p> 371 + </div> 372 + ``` 373 + 374 + **Note:** The existing `.placeholder` CSS class in `+page.svelte` (lines 152-160) already applies the correct styling for the `shamir_backup` placeholder. No new CSS needed. 375 + 376 + **Verification:** 377 + 378 + Run from `apps/identity-wallet/`: 379 + ```bash 380 + pnpm check 381 + ``` 382 + Expected: TypeScript type-check passes with no errors. 383 + 384 + Run from workspace root: 385 + ```bash 386 + cargo build -p identity-wallet 387 + ``` 388 + Expected: Rust backend compiles without errors. (This validates the Tauri command registration from Phase 3 is intact.) 389 + <!-- END_TASK_4 --> 390 + 391 + <!-- START_TASK_5 --> 392 + ### Task 5: Commit 393 + 394 + ```bash 395 + git add apps/identity-wallet/src/lib/ipc.ts \ 396 + apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte \ 397 + apps/identity-wallet/src/lib/components/onboarding/DIDSuccessScreen.svelte \ 398 + apps/identity-wallet/src/routes/+page.svelte 399 + git commit -m "feat(identity-wallet): add DID ceremony UI screens and IPC wrapper" 400 + ``` 401 + <!-- END_TASK_5 --> 402 + 403 + <!-- END_SUBCOMPONENT_A -->
+105
docs/implementation-plans/2026-03-20-MM-146/test-requirements.md
··· 1 + # MM-146 Test Requirements 2 + 3 + Maps every acceptance criterion from the MM-146 design plan to either an automated test or a documented human verification step. Rationalized against implementation decisions made during phase planning. 4 + 5 + --- 6 + 7 + ## Automated Tests 8 + 9 + ### MM-146.AC1: GET /v1/relay/keys returns active signing key 10 + 11 + All AC1 criteria are covered by integration tests in Phase 1, Task 3. Tests use `test_state()` (in-memory SQLite) and axum's `oneshot()` to exercise the full handler stack without a running server. 12 + 13 + | Criterion | Test Type | Test File | Test Function | Run Command | 14 + |---|---|---|---|---| 15 + | **AC1.1** Returns 200 with `{ keyId, publicKey, algorithm }` when a signing key is provisioned | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_200_with_active_key` | `cargo test -p relay get_relay` | 16 + | **AC1.2** Returns the most recently created key when multiple keys exist | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_most_recently_created_key` | `cargo test -p relay get_relay` | 17 + | **AC1.3** Returns 503 when no signing key is provisioned | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_returns_503_when_no_key_provisioned` | `cargo test -p relay get_relay` | 18 + | **AC1.4** Endpoint requires no authentication (public, no Bearer token) | Integration | `crates/relay/src/routes/get_relay_signing_key.rs` | `get_relay_keys_requires_no_authentication` | `cargo test -p relay get_relay` | 19 + 20 + **Implementation rationale:** These are standard axum handler integration tests following the pattern established by `create_signing_key.rs` and other route files. Each test inserts test data directly via sqlx and sends a request through the full router. AC1.4 is verified by the absence of an Authorization header in the request builder (`get_keys()` sends no auth header) combined with an assertion that the response is 200, not 401. 21 + 22 + --- 23 + 24 + ### MM-146.AC2: build_did_plc_genesis_op_with_external_signer produces valid genesis op 25 + 26 + All AC2 criteria are covered by unit tests in Phase 2, Task 4. Tests are appended to the existing `#[cfg(test)] mod tests` block in `plc.rs`. These are pure function tests with no I/O. 27 + 28 + | Criterion | Test Type | Test File | Test Function | Run Command | 29 + |---|---|---|---|---| 30 + | **AC2.1** Callback receives CBOR-encoded unsigned op bytes; returned `PlcGenesisOp` passes `verify_genesis_op` | Unit | `crates/crypto/src/plc.rs` | `external_signer_callback_produces_valid_genesis_op` | `cargo test -p crypto` | 31 + | **AC2.2** Callback returning `Err` propagates as `CryptoError::PlcOperation` | Unit | `crates/crypto/src/plc.rs` | `external_signer_callback_error_propagates_as_plc_operation` | `cargo test -p crypto` | 32 + | **AC2.3** Existing `build_did_plc_genesis_op` (now a wrapper) produces identical output to before | Unit (existing) | `crates/crypto/src/plc.rs` | *(all pre-existing tests in mod tests)* | `cargo test -p crypto` | 33 + 34 + **Implementation rationale:** AC2.1 generates a real P-256 keypair, passes a signing callback that uses the private key, and then verifies the resulting genesis op via `verify_genesis_op` -- the same verification function used in production. AC2.2 passes a callback that returns `Err(CryptoError::PlcOperation(...))` and asserts the error propagates unchanged. AC2.3 requires no new test code: the existing tests for `build_did_plc_genesis_op` exercise the refactored wrapper path implicitly. If the wrapper delegation introduces a regression, those existing tests fail. 35 + 36 + --- 37 + 38 + ### MM-146.AC3: perform_did_ceremony completes the full ceremony (partial automated coverage) 39 + 40 + AC3 criteria are split between automated serialization tests and human verification. The `perform_did_ceremony` Tauri command orchestrates Keychain (Apple system API), Secure Enclave (hardware), and HTTP calls to a real relay. These I/O boundaries cannot be meaningfully mocked in `cargo test` because: 41 + 42 + 1. Keychain APIs (`Security.framework`) require a running app context with entitlements. 43 + 2. Secure Enclave signing (`device_key::sign`) requires physical or simulated Apple hardware. 44 + 3. The `RelayClient` uses a compile-time `LazyLock<RelayClient>` singleton -- no dependency injection seam exists to substitute a mock HTTP client, and introducing one would add complexity beyond the scope of this feature. 45 + 46 + Phase 3, Task 3 explicitly acknowledges this gap and provides serialization-contract tests as the automated layer. 47 + 48 + | Criterion | Test Type | Test File | Test Function | Run Command | 49 + |---|---|---|---|---| 50 + | **AC3.4** `NoRelaySigningKey` serializes as `{ code: "NO_RELAY_SIGNING_KEY" }` | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_no_relay_signing_key_serializes_correctly` | `cargo test -p identity-wallet` | 51 + | **AC3.5** `RelayKeyFetchFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_relay_key_fetch_failed_serializes_correctly` | `cargo test -p identity-wallet` | 52 + | **AC3.6** `SigningFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_signing_failed_serializes_correctly` | `cargo test -p identity-wallet` | 53 + | **AC3.7** `DidCreationFailed` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_did_creation_failed_serializes_correctly` | `cargo test -p identity-wallet` | 54 + | *(supporting)* `DIDCeremonyResult` serializes `did` field in camelCase | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_result_serializes_did_in_camel_case` | `cargo test -p identity-wallet` | 55 + | *(supporting)* `KeyNotFound` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_key_not_found_serializes_correctly` | `cargo test -p identity-wallet` | 56 + | *(supporting)* `KeychainError` serializes correctly | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_keychain_error_serializes_correctly` | `cargo test -p identity-wallet` | 57 + | *(supporting)* `NetworkError` serializes with message field | Unit | `apps/identity-wallet/src-tauri/src/lib.rs` | `did_ceremony_error_network_error_serializes_with_message` | `cargo test -p identity-wallet` | 58 + 59 + **Implementation rationale:** The 8 serde tests verify the contract between Rust and TypeScript. If a variant's serialized `code` string changes, the TypeScript `DIDCeremonyError.code` discriminated union in `ipc.ts` will silently fail to match it. These tests catch that at compile/test time. The behavioral outcomes (AC3.1 through AC3.3, and the runtime error paths of AC3.4 through AC3.7) require human verification on an iOS simulator -- see the next section. 60 + 61 + --- 62 + 63 + ## Human Verification 64 + 65 + ### MM-146.AC3: perform_did_ceremony behavioral outcomes 66 + 67 + The following criteria require manual testing on an iOS Simulator (or device) with a running relay instance. They cannot be automated because they depend on Keychain persistence, Secure Enclave hardware signing, and live HTTP round-trips to a relay that has been provisioned with a signing key. 68 + 69 + | Criterion | Verification Approach | Justification | 70 + |---|---|---| 71 + | **AC3.1** Given a valid pending session token and provisioned relay key, returns `DIDCeremonyResult { did }` with a valid `did:plc` identifier | **iOS Simulator end-to-end flow:** (1) Start a local relay with a provisioned signing key. (2) Launch the app on the iOS Simulator. (3) Complete the account creation flow (claim code, email, handle). (4) Observe that the DID ceremony screen transitions to the DID success screen. (5) Verify the displayed DID starts with `did:plc:` and is 32 characters long. | The Tauri command touches Keychain, SE, and HTTP in sequence. No mock seam exists for any of these in the current architecture. | 72 + | **AC3.2** Keychain `"session-token"` is overwritten with the full session token from `POST /v1/dids` response | **Post-ceremony Keychain inspection:** After a successful ceremony in the simulator, use `security find-generic-password -s "ezpds-identity-wallet" -a "session-token" -w` in Terminal (or restart the app and verify it reads the upgraded token). Alternatively, add a temporary `tracing::info!` log in the `keychain::store_item` call and inspect Xcode console output. | Keychain writes require a running app with the correct entitlements. The value is set by `keychain::store_item`, which is an opaque `Security.framework` call. | 73 + | **AC3.3** Keychain `"did"` is populated with the resulting DID | **Post-ceremony Keychain inspection:** Same approach as AC3.2, using key `"did"` instead of `"session-token"`. Verify the stored value matches the DID shown on the success screen. | Same justification as AC3.2. | 74 + | **AC3.4** Returns `NoRelaySigningKey` when relay has no key (runtime behavior) | **iOS Simulator with empty relay:** (1) Start a local relay without provisioning a signing key. (2) Complete account creation. (3) Observe the DID ceremony screen shows the error message "The relay hasn't been configured yet. Please try again later." and a Retry button. | The serialization contract is tested automatically; this verifies the runtime HTTP 503 detection path. | 75 + | **AC3.5** Returns `RelayKeyFetchFailed` when `GET /v1/relay/keys` is unreachable (runtime behavior) | **iOS Simulator with relay stopped:** (1) Complete account creation with relay running. (2) Stop the relay process. (3) Observe the DID ceremony screen shows "Couldn't reach the server. Check your connection." and a Retry button. | Requires actual network failure -- cannot be simulated in a unit test without an HTTP mock layer. | 76 + | **AC3.6** Returns `SigningFailed` when SE signing fails (runtime behavior) | **Difficult to trigger intentionally.** SE signing failures are rare and hardware-dependent (e.g., key access revoked, biometric failure on a key with biometric policy). Verify indirectly: the error enum variant exists, serializes correctly (automated test), and the UI maps it to "Device signing failed. Please try again." (code review of `DIDCeremonyScreen.svelte`). | Secure Enclave failures cannot be reliably triggered in the simulator. The code path is verified via code review and the serialization unit test. | 77 + | **AC3.7** Returns `DidCreationFailed` when `POST /v1/dids` returns non-2xx (runtime behavior) | **iOS Simulator with relay returning errors:** (1) Provision the relay signing key. (2) Start the ceremony. (3) Cause `POST /v1/dids` to fail (e.g., use an already-promoted session token, or modify the relay to return 400). (4) Observe the DID ceremony screen shows "Couldn't create your identity. Please try again." and a Retry button. | Requires a specific relay state that produces a non-2xx response. Could also be verified with a proxy (e.g., mitmproxy) that intercepts and returns an error. | 78 + 79 + --- 80 + 81 + ### MM-146.AC4: DID ceremony UI 82 + 83 + No frontend test framework (Vitest, Playwright, etc.) is configured in the `apps/identity-wallet/` project. All UI criteria are verified manually on the iOS Simulator. The only automated frontend check is `pnpm check` (TypeScript/Svelte type-checking), which validates component props and IPC types at build time but does not render or interact with components. 84 + 85 + | Criterion | Verification Approach | Justification | 86 + |---|---|---| 87 + | **AC4.1** App shows loading screen with status text while ceremony is in flight | **iOS Simulator observation:** (1) Launch the app and complete account creation. (2) Observe that a loading screen appears with the text "Setting up your identity..." while the ceremony network calls are in progress. For slow-network testing, use Network Link Conditioner on the simulator to add latency. | UI rendering requires the Tauri runtime and a mobile WebView. `LoadingScreen.svelte` is a pre-existing component; this test confirms it is wired up correctly with the `statusText` prop. | 88 + | **AC4.2** On success, transitions to success screen showing truncated DID and a "Continue" button | **iOS Simulator observation:** (1) Complete a successful ceremony. (2) Verify the success screen appears with the heading "Identity Created!", a truncated DID in `did:plc:xxxxx...xxxx` format, and a "Continue" button. | Requires Tauri IPC round-trip to get a real DID and the Svelte rendering pipeline. | 89 + | **AC4.3** On failure, shows inline error message and a Retry button (does not rewind to previous screen) | **iOS Simulator with relay stopped or unconfigured:** (1) Trigger a ceremony failure (e.g., relay not running). (2) Verify the error message appears inline (red text) with a Retry button. (3) Verify the app does NOT navigate back to the handle or account creation screen. | Tests the error UI path end-to-end including the `catch` block in `DIDCeremonyScreen.svelte`. | 90 + | **AC4.4** Retry button re-invokes the ceremony from the beginning | **iOS Simulator retry flow:** (1) Trigger a failure (relay down). (2) Start the relay and provision a signing key. (3) Tap Retry. (4) Observe the loading screen reappears and the ceremony completes successfully, transitioning to the success screen. | Verifies that `runCeremony()` is called again from scratch (re-fetches device key, relay key, etc.) rather than resuming from a partial state. | 91 + | **AC4.5** "Continue" button transitions to `shamir_backup` placeholder step | **iOS Simulator observation:** (1) Complete a successful ceremony. (2) On the success screen, tap "Continue". (3) Verify the app transitions to a placeholder screen with the heading "Backup" and text "Shamir backup coming soon..." | Simple navigation check. Verifies the `oncontinue` callback in `DIDSuccessScreen.svelte` sets `step = 'shamir_backup'` in `+page.svelte`. | 92 + 93 + --- 94 + 95 + ## Coverage Summary 96 + 97 + | AC Group | Total Criteria | Automated | Human Verification | Notes | 98 + |---|---|---|---|---| 99 + | AC1 (Relay endpoint) | 4 | 4 | 0 | Full automated coverage via axum integration tests | 100 + | AC2 (Crypto external signer) | 3 | 3 | 0 | Full automated coverage; AC2.3 is implicit via existing tests | 101 + | AC3 (Tauri ceremony command) | 7 | 4 (serde) | 7 (behavioral) | Serialization contracts automated; behavioral outcomes require iOS Simulator. Four criteria have both automated (serde) and human (behavioral) verification. | 102 + | AC4 (Frontend UI) | 5 | 0 | 5 | No frontend test framework configured; all verified on iOS Simulator | 103 + | **Total** | **19** | **11** | **12** | Every criterion has at least one verification method | 104 + 105 + **Note on overlapping coverage:** AC3.4 through AC3.7 each appear in both the automated and human columns. The automated tests verify the serde serialization contract (the `code` string matches what TypeScript expects). The human verification confirms the runtime behavior (the correct error variant is produced when the real failure condition occurs). Both layers are necessary: a serialization-only test would miss a bug in the HTTP status code check, while a manual-only test would miss a serialization rename that breaks the TypeScript error handler.