diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 340a70d23d..8e6cebe582 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -105,6 +105,12 @@ jobs: NODE_BASE_URL: "http://localhost:${{ env.SERVER_PORT }}" # The e2e coverage recorder writes the endpoints it hit here. MERO_COVERAGE_OUT: "${{ github.workspace }}/_mero-js/covered-endpoints.json" + # The SDK harness bootstraps the node's first root key, so its password + # must satisfy the provider's minimum length (default 8). The harness + # would otherwise fall back to its own `dev`/`dev` default and be + # rejected at creation. + MERO_E2E_USER: dev + MERO_E2E_PASS: dev-password run: | pnpm install --frozen-lockfile pnpm test:e2e diff --git a/Cargo.lock b/Cargo.lock index 35290dca60..be9dc4519e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5956,6 +5956,7 @@ dependencies = [ "regex", "reqwest 0.11.27", "reqwest 0.12.28", + "ring", "rocksdb", "rust-embed", "serde", diff --git a/apps/blobs/workflows/blobs-example.yml b/apps/blobs/workflows/blobs-example.yml index de9f078ccb..4dd1773f16 100644 --- a/apps/blobs/workflows/blobs-example.yml +++ b/apps/blobs/workflows/blobs-example.yml @@ -21,13 +21,13 @@ steps: type: login node: node-blob-1 username: dev - password: dev + password: dev-password - name: Login on node-blob-2 type: login node: node-blob-2 username: dev - password: dev + password: dev-password # ============================================================================ # SETUP: Install Application & Create Context diff --git a/apps/blobs/workflows/blobs.yml b/apps/blobs/workflows/blobs.yml index f3f47affd6..e05a7adca6 100644 --- a/apps/blobs/workflows/blobs.yml +++ b/apps/blobs/workflows/blobs.yml @@ -18,13 +18,13 @@ steps: type: login node: rust-blob-1 username: dev - password: dev + password: dev-password - name: Login on rust-blob-2 type: login node: rust-blob-2 username: dev - password: dev + password: dev-password - name: Install Rust blobs app on inviter type: install_application diff --git a/apps/components-demo/workflows/components-demo.yml b/apps/components-demo/workflows/components-demo.yml index 041cb5e46b..ffdae933c5 100644 --- a/apps/components-demo/workflows/components-demo.yml +++ b/apps/components-demo/workflows/components-demo.yml @@ -25,13 +25,13 @@ steps: type: login node: new-calimero-node-1 username: dev - password: dev + password: dev-password - name: Login on new-calimero-node-2 type: login node: new-calimero-node-2 username: dev - password: dev + password: dev-password # Node 1 installs; the installer is config owner, sole settings writer, admin. - name: Install Application on Node 1 diff --git a/apps/kv-store-with-shared-storage/workflows/test_shared_storage.yml b/apps/kv-store-with-shared-storage/workflows/test_shared_storage.yml index a71ec01eed..61c71213fb 100644 --- a/apps/kv-store-with-shared-storage/workflows/test_shared_storage.yml +++ b/apps/kv-store-with-shared-storage/workflows/test_shared_storage.yml @@ -21,13 +21,13 @@ steps: type: login node: new-calimero-node-1 username: dev - password: dev + password: dev-password - name: Login on new-calimero-node-2 type: login node: new-calimero-node-2 username: dev - password: dev + password: dev-password # Step 1: Install on node 1. Installer becomes the sole initial writer. - name: Install Application on Node 1 diff --git a/apps/scaffolding-e2e/workflows/auto-follow/asymmetric-context-membership.yml b/apps/scaffolding-e2e/workflows/auto-follow/asymmetric-context-membership.yml index a960392e8e..048e89a747 100644 --- a/apps/scaffolding-e2e/workflows/auto-follow/asymmetric-context-membership.yml +++ b/apps/scaffolding-e2e/workflows/auto-follow/asymmetric-context-membership.yml @@ -75,13 +75,13 @@ steps: type: login node: af-asym-node-1 username: dev - password: dev + password: dev-password - name: Login on af-asym-node-2 type: login node: af-asym-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + both nodes joined ──────────────── - name: Install application on node-1 diff --git a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-backfill-on-join.yml b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-backfill-on-join.yml index 978ab74b85..d59e33dc50 100644 --- a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-backfill-on-join.yml +++ b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-backfill-on-join.yml @@ -54,13 +54,13 @@ steps: type: login node: af-backfill-node-1 username: dev - password: dev + password: dev-password - name: Login on af-backfill-node-2 type: login node: af-backfill-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap with pre-existing contexts BEFORE node-2 joins ─ - name: Install application on node-1 diff --git a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-flag-flipped-late.yml b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-flag-flipped-late.yml index a702004f7c..a8487ab43b 100644 --- a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-flag-flipped-late.yml +++ b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-flag-flipped-late.yml @@ -53,13 +53,13 @@ steps: type: login node: af-late-flip-node-1 username: dev - password: dev + password: dev-password - name: Login on af-late-flip-node-2 type: login node: af-late-flip-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Same starting shape as workflow 2 ────────────────────── - name: Install application on node-1 diff --git a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-on-context-registered.yml b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-on-context-registered.yml index 9a521ea3ae..fd64398608 100644 --- a/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-on-context-registered.yml +++ b/apps/scaffolding-e2e/workflows/auto-follow/auto-follow-on-context-registered.yml @@ -39,13 +39,13 @@ steps: type: login node: af-baseline-node-1 username: dev - password: dev + password: dev-password - name: Login on af-baseline-node-2 type: login node: af-baseline-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap two-node namespace + subgroup ──────────────── - name: Install kv-store-with-handlers on node-1 diff --git a/apps/scaffolding-e2e/workflows/auto-follow/no-unknown-context-rejection-spam.yml b/apps/scaffolding-e2e/workflows/auto-follow/no-unknown-context-rejection-spam.yml index 2d4d74a930..cbcbdf6802 100644 --- a/apps/scaffolding-e2e/workflows/auto-follow/no-unknown-context-rejection-spam.yml +++ b/apps/scaffolding-e2e/workflows/auto-follow/no-unknown-context-rejection-spam.yml @@ -79,13 +79,13 @@ steps: type: login node: af-no-spam-node-1 username: dev - password: dev + password: dev-password - name: Login on af-no-spam-node-2 type: login node: af-no-spam-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/dm-subgroup-privacy.yml b/apps/scaffolding-e2e/workflows/dm-subgroup-privacy.yml index 033727f423..b1ebb8992f 100644 --- a/apps/scaffolding-e2e/workflows/dm-subgroup-privacy.yml +++ b/apps/scaffolding-e2e/workflows/dm-subgroup-privacy.yml @@ -46,19 +46,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup: app + namespace on node-1 (the namespace owner) --- diff --git a/apps/scaffolding-e2e/workflows/e2e.yml b/apps/scaffolding-e2e/workflows/e2e.yml index 1ca5c4a448..dad6eded50 100644 --- a/apps/scaffolding-e2e/workflows/e2e.yml +++ b/apps/scaffolding-e2e/workflows/e2e.yml @@ -18,19 +18,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # SETUP: Install app and create mesh diff --git a/apps/scaffolding-e2e/workflows/frozen-rga-convergence.yml b/apps/scaffolding-e2e/workflows/frozen-rga-convergence.yml index 731b324540..29df57d03b 100644 --- a/apps/scaffolding-e2e/workflows/frozen-rga-convergence.yml +++ b/apps/scaffolding-e2e/workflows/frozen-rga-convergence.yml @@ -26,19 +26,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup: 3-node mesh --- - name: Install application on node-1 diff --git a/apps/scaffolding-e2e/workflows/group-3node.yml b/apps/scaffolding-e2e/workflows/group-3node.yml index c37288b7dc..2d34ea0b34 100644 --- a/apps/scaffolding-e2e/workflows/group-3node.yml +++ b/apps/scaffolding-e2e/workflows/group-3node.yml @@ -21,19 +21,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-capabilities.yml b/apps/scaffolding-e2e/workflows/group-capabilities.yml index 18f0687623..4516d7f58a 100644 --- a/apps/scaffolding-e2e/workflows/group-capabilities.yml +++ b/apps/scaffolding-e2e/workflows/group-capabilities.yml @@ -26,13 +26,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-context-isolation.yml b/apps/scaffolding-e2e/workflows/group-context-isolation.yml index a5414b7eb7..2081be8389 100644 --- a/apps/scaffolding-e2e/workflows/group-context-isolation.yml +++ b/apps/scaffolding-e2e/workflows/group-context-isolation.yml @@ -22,13 +22,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-default-capabilities-propagation.yml b/apps/scaffolding-e2e/workflows/group-default-capabilities-propagation.yml index 86a25dc93f..b8bfbb91f4 100644 --- a/apps/scaffolding-e2e/workflows/group-default-capabilities-propagation.yml +++ b/apps/scaffolding-e2e/workflows/group-default-capabilities-propagation.yml @@ -55,13 +55,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-governance-late-joiner.yml b/apps/scaffolding-e2e/workflows/group-governance-late-joiner.yml index 57a980f9ea..9b1bcde403 100644 --- a/apps/scaffolding-e2e/workflows/group-governance-late-joiner.yml +++ b/apps/scaffolding-e2e/workflows/group-governance-late-joiner.yml @@ -22,13 +22,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/group-governance-peer-down.yml b/apps/scaffolding-e2e/workflows/group-governance-peer-down.yml index 790f9151de..0243f5b570 100644 --- a/apps/scaffolding-e2e/workflows/group-governance-peer-down.yml +++ b/apps/scaffolding-e2e/workflows/group-governance-peer-down.yml @@ -22,19 +22,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/group-join-via-inheritance.yml b/apps/scaffolding-e2e/workflows/group-join-via-inheritance.yml index c528250760..4b1c1bf884 100644 --- a/apps/scaffolding-e2e/workflows/group-join-via-inheritance.yml +++ b/apps/scaffolding-e2e/workflows/group-join-via-inheritance.yml @@ -50,13 +50,13 @@ steps: type: login node: join-inherit-node-1 username: dev - password: dev + password: dev-password - name: Login on join-inherit-node-2 type: login node: join-inherit-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap mesh, namespace, Open subgroup, context ──── diff --git a/apps/scaffolding-e2e/workflows/group-kick-and-readd-deny-list.yml b/apps/scaffolding-e2e/workflows/group-kick-and-readd-deny-list.yml index 90ac249f18..9ae39c8cdc 100644 --- a/apps/scaffolding-e2e/workflows/group-kick-and-readd-deny-list.yml +++ b/apps/scaffolding-e2e/workflows/group-kick-and-readd-deny-list.yml @@ -52,19 +52,19 @@ steps: type: login node: kick-readd-node-1 username: dev - password: dev + password: dev-password - name: Login on kick-readd-node-2 type: login node: kick-readd-node-2 username: dev - password: dev + password: dev-password - name: Login on kick-readd-node-3 type: login node: kick-readd-node-3 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + Restricted subgroup + context ── diff --git a/apps/scaffolding-e2e/workflows/group-kick-and-rejoin-keyshare.yml b/apps/scaffolding-e2e/workflows/group-kick-and-rejoin-keyshare.yml index 14ca5f349a..7dfec7ccca 100644 --- a/apps/scaffolding-e2e/workflows/group-kick-and-rejoin-keyshare.yml +++ b/apps/scaffolding-e2e/workflows/group-kick-and-rejoin-keyshare.yml @@ -58,19 +58,19 @@ steps: type: login node: kick-rejoin-node-1 username: dev - password: dev + password: dev-password - name: Login on kick-rejoin-node-2 type: login node: kick-rejoin-node-2 username: dev - password: dev + password: dev-password - name: Login on kick-rejoin-node-3 type: login node: kick-rejoin-node-3 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + Open subgroup + context ──────── diff --git a/apps/scaffolding-e2e/workflows/group-late-joiner.yml b/apps/scaffolding-e2e/workflows/group-late-joiner.yml index aa11fd419d..16bdce4389 100644 --- a/apps/scaffolding-e2e/workflows/group-late-joiner.yml +++ b/apps/scaffolding-e2e/workflows/group-late-joiner.yml @@ -22,13 +22,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup: node-1 alone creates namespace and context --- diff --git a/apps/scaffolding-e2e/workflows/group-leave-context.yml b/apps/scaffolding-e2e/workflows/group-leave-context.yml index 4ceda6ae2a..3204ed8db7 100644 --- a/apps/scaffolding-e2e/workflows/group-leave-context.yml +++ b/apps/scaffolding-e2e/workflows/group-leave-context.yml @@ -21,13 +21,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/group-leave-member.yml b/apps/scaffolding-e2e/workflows/group-leave-member.yml index 9c8df2cfca..3f5866d0b6 100644 --- a/apps/scaffolding-e2e/workflows/group-leave-member.yml +++ b/apps/scaffolding-e2e/workflows/group-leave-member.yml @@ -21,19 +21,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/group-leave-namespace.yml b/apps/scaffolding-e2e/workflows/group-leave-namespace.yml index 99672a9acf..858abe81e5 100644 --- a/apps/scaffolding-e2e/workflows/group-leave-namespace.yml +++ b/apps/scaffolding-e2e/workflows/group-leave-namespace.yml @@ -22,13 +22,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/group-leave-then-rejoin-via-inheritance.yml b/apps/scaffolding-e2e/workflows/group-leave-then-rejoin-via-inheritance.yml index 69bb7bb040..40267f5b4c 100644 --- a/apps/scaffolding-e2e/workflows/group-leave-then-rejoin-via-inheritance.yml +++ b/apps/scaffolding-e2e/workflows/group-leave-then-rejoin-via-inheritance.yml @@ -55,19 +55,19 @@ steps: type: login node: leave-rejoin-node-1 username: dev - password: dev + password: dev-password - name: Login on leave-rejoin-node-2 type: login node: leave-rejoin-node-2 username: dev - password: dev + password: dev-password - name: Login on leave-rejoin-node-3 type: login node: leave-rejoin-node-3 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + Open subgroup + context ──────── diff --git a/apps/scaffolding-e2e/workflows/group-list-subgroups-restricted-hidden.yml b/apps/scaffolding-e2e/workflows/group-list-subgroups-restricted-hidden.yml index 7d5dbb120e..55f93a408c 100644 --- a/apps/scaffolding-e2e/workflows/group-list-subgroups-restricted-hidden.yml +++ b/apps/scaffolding-e2e/workflows/group-list-subgroups-restricted-hidden.yml @@ -48,19 +48,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup: app + namespace on node-1 (the namespace owner) --- diff --git a/apps/scaffolding-e2e/workflows/group-member-channel-lifecycle.yml b/apps/scaffolding-e2e/workflows/group-member-channel-lifecycle.yml index 17ad7c55ab..c5fc73d6c0 100644 --- a/apps/scaffolding-e2e/workflows/group-member-channel-lifecycle.yml +++ b/apps/scaffolding-e2e/workflows/group-member-channel-lifecycle.yml @@ -40,13 +40,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-membership.yml b/apps/scaffolding-e2e/workflows/group-membership.yml index 8733b87eed..47a49ac6bf 100644 --- a/apps/scaffolding-e2e/workflows/group-membership.yml +++ b/apps/scaffolding-e2e/workflows/group-membership.yml @@ -22,19 +22,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup: install app, create namespace, invite all nodes --- diff --git a/apps/scaffolding-e2e/workflows/group-metadata.yml b/apps/scaffolding-e2e/workflows/group-metadata.yml index d97defd3d4..8ffcf8060d 100644 --- a/apps/scaffolding-e2e/workflows/group-metadata.yml +++ b/apps/scaffolding-e2e/workflows/group-metadata.yml @@ -41,13 +41,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Phase 1: Bootstrap namespace + subgroup + context on node-1 --- diff --git a/apps/scaffolding-e2e/workflows/group-multi-service.yml b/apps/scaffolding-e2e/workflows/group-multi-service.yml index 64a5e1545a..915df74598 100644 --- a/apps/scaffolding-e2e/workflows/group-multi-service.yml +++ b/apps/scaffolding-e2e/workflows/group-multi-service.yml @@ -26,13 +26,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup: install multi-service bundle --- diff --git a/apps/scaffolding-e2e/workflows/group-open-self-join-key-delivery.yml b/apps/scaffolding-e2e/workflows/group-open-self-join-key-delivery.yml index 3d24cdae10..671ec99eb6 100644 --- a/apps/scaffolding-e2e/workflows/group-open-self-join-key-delivery.yml +++ b/apps/scaffolding-e2e/workflows/group-open-self-join-key-delivery.yml @@ -52,13 +52,13 @@ steps: type: login node: open-selfjoin-node-1 username: dev - password: dev + password: dev-password - name: Login on open-selfjoin-node-2 type: login node: open-selfjoin-node-2 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap mesh, namespace, Open subgroup, context ──── diff --git a/apps/scaffolding-e2e/workflows/group-private-add-then-write.yml b/apps/scaffolding-e2e/workflows/group-private-add-then-write.yml index 220c4b25eb..59133a5bc0 100644 --- a/apps/scaffolding-e2e/workflows/group-private-add-then-write.yml +++ b/apps/scaffolding-e2e/workflows/group-private-add-then-write.yml @@ -50,19 +50,19 @@ steps: type: login node: private-add-node-1 username: dev - password: dev + password: dev-password - name: Login on private-add-node-2 type: login node: private-add-node-2 username: dev - password: dev + password: dev-password - name: Login on private-add-node-3 type: login node: private-add-node-3 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + Restricted subgroup ──────────── diff --git a/apps/scaffolding-e2e/workflows/group-remove-from-root-revokes-inherited.yml b/apps/scaffolding-e2e/workflows/group-remove-from-root-revokes-inherited.yml index 07de619eb7..428d74ba75 100644 --- a/apps/scaffolding-e2e/workflows/group-remove-from-root-revokes-inherited.yml +++ b/apps/scaffolding-e2e/workflows/group-remove-from-root-revokes-inherited.yml @@ -65,19 +65,19 @@ steps: type: login node: revoke-inherit-node-1 username: dev - password: dev + password: dev-password - name: Login on revoke-inherit-node-2 type: login node: revoke-inherit-node-2 username: dev - password: dev + password: dev-password - name: Login on revoke-inherit-node-3 type: login node: revoke-inherit-node-3 username: dev - password: dev + password: dev-password # ── Phase 1: Bootstrap namespace + Open subgroup + context ──────── diff --git a/apps/scaffolding-e2e/workflows/group-reparent-and-cascade-delete.yml b/apps/scaffolding-e2e/workflows/group-reparent-and-cascade-delete.yml index 2655717b86..2fefe6b5c6 100644 --- a/apps/scaffolding-e2e/workflows/group-reparent-and-cascade-delete.yml +++ b/apps/scaffolding-e2e/workflows/group-reparent-and-cascade-delete.yml @@ -27,7 +27,7 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-reparent.yml b/apps/scaffolding-e2e/workflows/group-reparent.yml index f624000a7a..71342a1c3e 100644 --- a/apps/scaffolding-e2e/workflows/group-reparent.yml +++ b/apps/scaffolding-e2e/workflows/group-reparent.yml @@ -30,13 +30,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-subgroup-cold-sync.yml b/apps/scaffolding-e2e/workflows/group-subgroup-cold-sync.yml index 5d9f92c0a7..68e0b7ab9b 100644 --- a/apps/scaffolding-e2e/workflows/group-subgroup-cold-sync.yml +++ b/apps/scaffolding-e2e/workflows/group-subgroup-cold-sync.yml @@ -44,13 +44,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # ── Setup: install, namespace, invite node-2 ─────────────────────── diff --git a/apps/scaffolding-e2e/workflows/group-subgroup-queued-deltas.yml b/apps/scaffolding-e2e/workflows/group-subgroup-queued-deltas.yml index bb51fccbda..f511b6e35f 100644 --- a/apps/scaffolding-e2e/workflows/group-subgroup-queued-deltas.yml +++ b/apps/scaffolding-e2e/workflows/group-subgroup-queued-deltas.yml @@ -58,13 +58,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # ── Setup: install app on both nodes ────────────────────────────── diff --git a/apps/scaffolding-e2e/workflows/group-subgroup-visibility-inheritance.yml b/apps/scaffolding-e2e/workflows/group-subgroup-visibility-inheritance.yml index 43b78e2b24..682911d330 100644 --- a/apps/scaffolding-e2e/workflows/group-subgroup-visibility-inheritance.yml +++ b/apps/scaffolding-e2e/workflows/group-subgroup-visibility-inheritance.yml @@ -43,13 +43,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/group-subgroup.yml b/apps/scaffolding-e2e/workflows/group-subgroup.yml index 98ed8a68cc..59ffb1a712 100644 --- a/apps/scaffolding-e2e/workflows/group-subgroup.yml +++ b/apps/scaffolding-e2e/workflows/group-subgroup.yml @@ -25,13 +25,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup --- diff --git a/apps/scaffolding-e2e/workflows/groups.yml b/apps/scaffolding-e2e/workflows/groups.yml index bc53fab341..5ca9d86ea3 100644 --- a/apps/scaffolding-e2e/workflows/groups.yml +++ b/apps/scaffolding-e2e/workflows/groups.yml @@ -20,13 +20,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- SCENARIO 1: Full namespace lifecycle --- diff --git a/apps/scaffolding-e2e/workflows/handler-test.yml b/apps/scaffolding-e2e/workflows/handler-test.yml index 26d7edab85..ebdb543dd2 100644 --- a/apps/scaffolding-e2e/workflows/handler-test.yml +++ b/apps/scaffolding-e2e/workflows/handler-test.yml @@ -24,13 +24,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/hashcomparison-shared-user-reconcile.yml b/apps/scaffolding-e2e/workflows/hashcomparison-shared-user-reconcile.yml index 109fb50af4..3c1c1322fe 100644 --- a/apps/scaffolding-e2e/workflows/hashcomparison-shared-user-reconcile.yml +++ b/apps/scaffolding-e2e/workflows/hashcomparison-shared-user-reconcile.yml @@ -86,13 +86,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Phase 1: bootstrap; both nodes join the namespace + context # and write a baseline value so both end up with diff --git a/apps/scaffolding-e2e/workflows/kv-store-joiner-cold.yml b/apps/scaffolding-e2e/workflows/kv-store-joiner-cold.yml index 577ddff13b..8e66fc566b 100644 --- a/apps/scaffolding-e2e/workflows/kv-store-joiner-cold.yml +++ b/apps/scaffolding-e2e/workflows/kv-store-joiner-cold.yml @@ -27,13 +27,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Setup: node-1 alone creates namespace and context --- diff --git a/apps/scaffolding-e2e/workflows/member-removed-late-joiner-catchup.yml b/apps/scaffolding-e2e/workflows/member-removed-late-joiner-catchup.yml index 46af06c87c..1264ee3208 100644 --- a/apps/scaffolding-e2e/workflows/member-removed-late-joiner-catchup.yml +++ b/apps/scaffolding-e2e/workflows/member-removed-late-joiner-catchup.yml @@ -43,19 +43,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/member-removed-multi-context-convergence.yml b/apps/scaffolding-e2e/workflows/member-removed-multi-context-convergence.yml index 4b14d45c41..48fc224077 100644 --- a/apps/scaffolding-e2e/workflows/member-removed-multi-context-convergence.yml +++ b/apps/scaffolding-e2e/workflows/member-removed-multi-context-convergence.yml @@ -41,19 +41,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # --- Setup: app + namespace owned by node-1 --- diff --git a/apps/scaffolding-e2e/workflows/mesh-soak-8node.yml b/apps/scaffolding-e2e/workflows/mesh-soak-8node.yml index 29ce1345f9..3420f438a3 100644 --- a/apps/scaffolding-e2e/workflows/mesh-soak-8node.yml +++ b/apps/scaffolding-e2e/workflows/mesh-soak-8node.yml @@ -25,49 +25,49 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password - name: Login on e2e-node-4 type: login node: e2e-node-4 username: dev - password: dev + password: dev-password - name: Login on e2e-node-5 type: login node: e2e-node-5 username: dev - password: dev + password: dev-password - name: Login on e2e-node-6 type: login node: e2e-node-6 username: dev - password: dev + password: dev-password - name: Login on e2e-node-7 type: login node: e2e-node-7 username: dev - password: dev + password: dev-password - name: Login on e2e-node-8 type: login node: e2e-node-8 username: dev - password: dev + password: dev-password # --- Setup on node-1 --- diff --git a/apps/scaffolding-e2e/workflows/root-bootstrap-converge.yml b/apps/scaffolding-e2e/workflows/root-bootstrap-converge.yml index f6a33dc1ab..a0a3091c89 100644 --- a/apps/scaffolding-e2e/workflows/root-bootstrap-converge.yml +++ b/apps/scaffolding-e2e/workflows/root-bootstrap-converge.yml @@ -70,13 +70,13 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password # --- Phase 1: node-1 alone seeds the context with several writes --- diff --git a/apps/scaffolding-e2e/workflows/shared-storage-concurrent-rotation-converge.yml b/apps/scaffolding-e2e/workflows/shared-storage-concurrent-rotation-converge.yml index 44a8c230bd..a6666da37c 100644 --- a/apps/scaffolding-e2e/workflows/shared-storage-concurrent-rotation-converge.yml +++ b/apps/scaffolding-e2e/workflows/shared-storage-concurrent-rotation-converge.yml @@ -97,19 +97,19 @@ steps: type: login node: e2e-node-1 username: dev - password: dev + password: dev-password - name: Login on e2e-node-2 type: login node: e2e-node-2 username: dev - password: dev + password: dev-password - name: Login on e2e-node-3 type: login node: e2e-node-3 username: dev - password: dev + password: dev-password # ---------- Setup: context with writer set {A} ---------- diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-network-partition-symmetric.yml b/apps/scaffolding-e2e/workflows/sync-resilience-network-partition-symmetric.yml index 43f1a58886..b296afadf3 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-network-partition-symmetric.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-network-partition-symmetric.yml @@ -58,13 +58,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-network-partition.yml b/apps/scaffolding-e2e/workflows/sync-resilience-network-partition.yml index 59712a98dd..6da9a5a635 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-network-partition.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-network-partition.yml @@ -91,13 +91,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password # ---------- Setup ---------- diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause-symmetric.yml b/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause-symmetric.yml index bde26fa5ed..3cf7c0c86c 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause-symmetric.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause-symmetric.yml @@ -53,13 +53,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password - name: Install application on node-1 type: install_application diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause.yml b/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause.yml index 7b10fab9bf..d5bb857837 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-peer-pause.yml @@ -49,13 +49,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password # ---------- Setup ---------- diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-autoresync.yml b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-autoresync.yml index b81fbac14a..219cb75325 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-autoresync.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-autoresync.yml @@ -62,13 +62,13 @@ steps: type: login node: sync-autoresync-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-autoresync-node-2 type: login node: sync-autoresync-node-2 username: dev - password: dev + password: dev-password # ---------- Setup: 2-node namespace + context ---------- diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-symmetric.yml b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-symmetric.yml index ae59c92883..98e66bdbf1 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-symmetric.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart-symmetric.yml @@ -53,13 +53,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password # Steps mirror the cone variant exactly. Setup → baseline → fault # (restart node-1) → recovery → assert post-restart write diff --git a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart.yml b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart.yml index b9e9f9d7c4..30289ca68d 100644 --- a/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart.yml +++ b/apps/scaffolding-e2e/workflows/sync-resilience-peer-restart.yml @@ -57,13 +57,13 @@ steps: type: login node: sync-resil-node-1 username: dev - password: dev + password: dev-password - name: Login on sync-resil-node-2 type: login node: sync-resil-node-2 username: dev - password: dev + password: dev-password # ---------- Setup: 2-node namespace + context ---------- diff --git a/apps/xcall-example/workflows/xcall.yml b/apps/xcall-example/workflows/xcall.yml index f2981390c6..0a5ad43222 100644 --- a/apps/xcall-example/workflows/xcall.yml +++ b/apps/xcall-example/workflows/xcall.yml @@ -55,7 +55,7 @@ steps: type: login node: xcall-authz-1 username: dev - password: dev + password: dev-password # ---------------- setup: namespace N ⊃ {C1, C2}, namespace M ⊃ {C3} ---------------- - name: Install app on Node 1 diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index d97ca0563e..c5bb48c4c1 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -32,6 +32,7 @@ lazy_static.workspace = true parking_lot.workspace = true rand.workspace = true regex = "1.10.2" +ring.workspace = true rocksdb.workspace = true rust-embed = { workspace = true, features = ["mime-guess", "interpolate-folder-path"] } serde = { workspace = true, features = ["derive"] } diff --git a/crates/auth/src/auth/token/jwt.rs b/crates/auth/src/auth/token/jwt.rs index 9c76a0479c..c9a711bac9 100644 --- a/crates/auth/src/auth/token/jwt.rs +++ b/crates/auth/src/auth/token/jwt.rs @@ -120,18 +120,31 @@ impl TokenManager { .or_else(|| headers.get("host")) .and_then(|h| h.to_str().ok()); - // This is only reached for a node-bound token (the caller invokes it - // exactly when `node_url` is set). If the request carries no - // determinable host, we cannot prove it is addressed to the bound node - // — fail closed. Letting a `None` host fall through to `Ok(())` would - // let any client strip the `Host`/`X-Forwarded-Host` header and bypass - // node binding entirely. - let Some(request_host) = request_host else { - return Err( - "Token is node-bound but the request carries no Host or X-Forwarded-Host \ - header to validate against" - .to_owned(), - ); + Self::validate_node_host_match(token_node_url, request_host) + } + + /// Pure host-binding comparison, separated for testability. + /// + /// This is only reached for a node-bound token (the caller invokes it + /// exactly when `node_url` is set). If the request carries no determinable + /// host, we cannot prove it is addressed to the bound node — fail + /// **closed**. Letting a missing (or empty) host fall through to `Ok(())` + /// would let any client strip the `Host`/`X-Forwarded-Host` header and + /// bypass node binding entirely. + fn validate_node_host_match( + token_node_url: &str, + request_host: Option<&str>, + ) -> Result<(), String> { + let request_host = match request_host { + Some(host) if !host.trim().is_empty() => host, + // Fail closed: no host on a node-bound token is not trustworthy. + _ => { + return Err( + "Token is node-bound but the request carries no Host or X-Forwarded-Host \ + header to validate against" + .to_owned(), + ); + } }; // Skip validation if request is coming from internal auth service. @@ -145,16 +158,20 @@ impl TokenManager { // URL (it can carry a client name), so an unparseable value is left to // fall through rather than rejected here — the missing-request-host case // above is the binding-bypass this guards. - if let Ok(token_url) = Url::parse(token_node_url) { - if let Some(token_host) = token_url.host_str() { - // Compare the hosts (handle both with and without port) - let request_host_without_port = - request_host.split(':').next().unwrap_or(request_host); - if request_host_without_port != token_host && request_host != token_host { - return Err(format!( - "Token is not valid for this host. Token is for '{token_host}' but request is to '{request_host}'" - )); - } + let token_host = Url::parse(token_node_url) + .ok() + .and_then(|url| url.host_str().map(|h| h.to_ascii_lowercase())); + + if let Some(token_host) = token_host { + // Compare the hosts (handle both with and without port); host + // names are case-insensitive, so compare lowercased. + let request_host_without_port = request_host.split(':').next().unwrap_or(request_host); + let request_host_lc = request_host.to_ascii_lowercase(); + let request_host_without_port_lc = request_host_without_port.to_ascii_lowercase(); + if request_host_without_port_lc != token_host && request_host_lc != token_host { + return Err(format!( + "Token is not valid for this host. Token is for '{token_host}' but request is to '{request_host}'" + )); } } @@ -729,6 +746,54 @@ mod tests { assert!(!TokenManager::is_internal_auth_service("auth: 3001")); } + // --- #11: node-host binding fail-closed ----------------------------- + + #[test] + fn test_validate_node_host_match_exact_accepted() { + assert!(TokenManager::validate_node_host_match( + "https://node.example.com", + Some("node.example.com"), + ) + .is_ok()); + assert!(TokenManager::validate_node_host_match( + "https://node.example.com", + Some("node.example.com:8443"), + ) + .is_ok()); + } + + #[test] + fn test_validate_node_host_match_suffix_attack_rejected() { + assert!(TokenManager::validate_node_host_match( + "https://node.example.com", + Some("node.example.com.attacker.com"), + ) + .is_err()); + } + + #[test] + fn test_validate_node_host_match_absent_host_fails_closed() { + // No host present at all -> reject (previously this passed). + assert!(TokenManager::validate_node_host_match("https://node.example.com", None).is_err()); + // Empty / whitespace host header -> reject. + assert!( + TokenManager::validate_node_host_match("https://node.example.com", Some("")).is_err() + ); + assert!( + TokenManager::validate_node_host_match("https://node.example.com", Some(" ")) + .is_err() + ); + } + + #[test] + fn test_validate_node_host_match_internal_auth_service_allowed() { + assert!(TokenManager::validate_node_host_match( + "https://node.example.com", + Some("auth:3001") + ) + .is_ok()); + } + async fn test_token_manager() -> TokenManager { use crate::config::JwtConfig; use crate::secrets::SecretManager; diff --git a/crates/auth/src/providers/impls/user_password.rs b/crates/auth/src/providers/impls/user_password.rs index 7e41717546..b80af27b7f 100644 --- a/crates/auth/src/providers/impls/user_password.rs +++ b/crates/auth/src/providers/impls/user_password.rs @@ -1,13 +1,15 @@ use std::any::Any; +use std::num::NonZeroU32; use std::sync::Arc; use async_trait::async_trait; use axum::body::Body; use axum::http::Request; +use ring::pbkdf2; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; -use tracing::{debug, error}; +use tracing::{debug, error, info, warn}; use validator::Validate; use crate::api::handlers::auth::TokenRequest; @@ -21,6 +23,91 @@ use crate::storage::models::Key; use crate::storage::{KeyManager, Storage}; use crate::{register_auth_data_type, register_auth_provider, AuthResponse}; +/// Application-wide salt prefix for deriving the username/password key id. +/// +/// The key id doubles as the storage lookup key, so it must be reproducible +/// from the credentials alone, with no per-user state stored before lookup. +/// A per-user *random* salt is therefore impossible in this model. We instead +/// derive a per-user salt deterministically as `KEY_ID_SALT_PREFIX || username` +/// (the username is known at lookup time), which defeats cross-user precomputed +/// (rainbow) tables, and rely on the PBKDF2 iteration count for offline +/// brute-force resistance. Tradeoff: because the salt is derived (not random), +/// an attacker who learns the scheme can still mount a per-target attack, but +/// that attack is now PBKDF2-stretched rather than a single unsalted SHA256. +const KEY_ID_SALT_PREFIX: &[u8] = b"calimero:auth:user_password:key-id:v1:"; + +/// PBKDF2 iteration count for key-id derivation. +const KEY_ID_PBKDF2_ITERATIONS: u32 = 100_000; + +/// Length of the derived key-id, in bytes (256-bit, hex-encoded to 64 chars). +const KEY_ID_LEN: usize = 32; + +/// Deterministically derive the storage key id from credentials using a +/// per-user-salted PBKDF2-HMAC-SHA256, replacing the previous unsalted SHA256. +fn derive_key_id(username: &str, password: &str) -> String { + // Per-user deterministic salt: fixed domain-separation prefix + username. + let mut salt = Vec::with_capacity(KEY_ID_SALT_PREFIX.len() + username.len()); + salt.extend_from_slice(KEY_ID_SALT_PREFIX); + salt.extend_from_slice(username.as_bytes()); + + // Iteration count is a non-zero compile-time constant. + let iterations = NonZeroU32::new(KEY_ID_PBKDF2_ITERATIONS) + .expect("KEY_ID_PBKDF2_ITERATIONS must be non-zero"); + + let mut out = [0u8; KEY_ID_LEN]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA256, + iterations, + &salt, + password.as_bytes(), + &mut out, + ); + hex::encode(out) +} + +/// The pre-PBKDF2 key-id derivation: an unsalted `SHA256("user_password:{u}:{p}")`. +/// +/// Retained ONLY so that a node upgraded from a release that used this scheme +/// can still find the root key it already stored, and transparently re-key it to +/// the salted derivation on the next successful login (see +/// [`UserPasswordProvider::verify_credentials`]). Never used to *create* a key. +fn legacy_key_id(username: &str, password: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("user_password:{username}:{password}").as_bytes()); + hex::encode(hasher.finalize()) +} + +/// Enforce configured password length bounds. +/// +/// Returns a clear validation error when the password is shorter than +/// `min_length` or longer than `max_length`. Length is measured in Unicode +/// scalar values (`chars`), not bytes. +fn validate_password_length( + password: &str, + min_length: usize, + max_length: usize, +) -> eyre::Result<()> { + let len = password.chars().count(); + if len < min_length { + eyre::bail!("Password must be at least {min_length} characters long"); + } + if len > max_length { + eyre::bail!("Password must be at most {max_length} characters long"); + } + Ok(()) +} + +/// Guard the KDF against absurdly long inputs on the *authentication* path. +/// +/// The minimum length is a policy for NEW credentials and is deliberately NOT +/// enforced here: an existing user whose password predates the policy must still +/// be able to log in (enforcing the minimum at login would lock them out of +/// their own node, with no recovery path). The maximum is still enforced because +/// it bounds PBKDF2 work per request. +fn validate_password_for_auth(password: &str, max_length: usize) -> eyre::Result<()> { + validate_password_length(password, 0, max_length) +} + /// Username/password authentication data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserPasswordAuthData { @@ -84,10 +171,61 @@ impl UserPasswordProvider { /// /// * `String` - The generated key ID fn generate_key_id(&self, username: &str, password: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(format!("user_password:{username}:{password}").as_bytes()); - let hash = hasher.finalize(); - hex::encode(hash) + derive_key_id(username, password) + } + + /// Enforce the configured password length bounds for this provider. + /// + /// Creation-path policy (bootstrap / `create_root_key`) — enforces BOTH the + /// minimum and the maximum. + fn validate_password(&self, password: &str) -> eyre::Result<()> { + validate_password_length( + password, + self.config.min_password_length, + self.config.max_password_length, + ) + } + + /// Re-key a root key stored under the legacy unsalted-SHA256 id onto the + /// salted-KDF id, in place, on a successful credential match. + /// + /// Returns the key under its NEW id when the migration applies, `None` when + /// no legacy key exists for these credentials (i.e. the credentials are + /// simply wrong). The move is write-then-delete: if the process dies between + /// the two, the key exists under both ids and the next login resolves via + /// the new id — never a state where the key is gone. + async fn migrate_legacy_key( + &self, + username: &str, + password: &str, + new_key_id: &str, + ) -> eyre::Result> { + let legacy_id = legacy_key_id(username, password); + + let Some(key) = self.key_manager.get_key(&legacy_id).await? else { + return Ok(None); + }; + if !key.is_valid() || !key.is_root_key() { + return Ok(None); + } + + // Store under the new id first so a crash can't lose the key. + self.key_manager.set_key(new_key_id, &key).await?; + if let Err(e) = self.key_manager.delete_key(&legacy_id).await { + // The new id is already usable; a stale legacy copy is harmless and + // will be retried on the next login. Don't fail the login for it. + warn!( + error = %e, + "Migrated user_password key id but failed to delete the legacy entry" + ); + } + + info!( + user = %crate::utils::sanitize_for_log(username), + "Migrated user_password root key from the legacy unsalted key id to the salted KDF id" + ); + + Ok(Some((new_key_id.to_string(), key))) } /// Verify username and password by checking if corresponding root key exists @@ -117,7 +255,13 @@ impl UserPasswordProvider { Ok(None) } } - Ok(None) => Ok(None), + // No key under the salted id. Before rejecting, check whether this + // node still stores the key under the OLD unsalted-SHA256 id: on an + // upgraded node every pre-existing user is in exactly that state, and + // without this migration they would be permanently locked out of + // their own node (the bootstrap path cannot rescue them either — it + // only fires when NO root key exists). + Ok(None) => self.migrate_legacy_key(username, password, &key_id).await, Err(err) => { error!("Failed to get root key: {}", err); Err(eyre::eyre!("Failed to verify credentials: {}", err)) @@ -136,6 +280,10 @@ impl UserPasswordProvider { /// /// * `eyre::Result<(String, Key)>` - The created key ID and root key async fn create_root_key(&self, username: &str, password: &str) -> eyre::Result<(String, Key)> { + // Creation path: the configured length policy (min AND max) applies to + // every NEW credential. + self.validate_password(password)?; + // Generate key ID from username/password let key_id = self.generate_key_id(username, password); @@ -171,6 +319,13 @@ impl UserPasswordProvider { username: &str, password: &str, ) -> eyre::Result<(String, Vec)> { + // On the authentication path enforce only the MAXIMUM length (a bound on + // PBKDF2 work per request). The minimum is a policy for new credentials + // and is enforced in `create_root_key` below: applying it here would + // reject an existing user whose password predates the policy, locking + // them out of their own node with no recovery path. + validate_password_for_auth(password, self.config.max_password_length)?; + // Try to verify existing credentials if let Some((key_id, root_key)) = self.verify_credentials(username, password).await? { // Existing user - return their key ID and permissions @@ -394,6 +549,9 @@ impl AuthProvider for UserPasswordProvider { .and_then(Value::as_str) .ok_or_else(|| eyre::eyre!("Missing or invalid 'password' in provider data"))?; + // Enforce password length bounds before creating the root key. + self.validate_password(password)?; + // Generate key ID from username/password let key_id = self.generate_key_id(username, password); @@ -452,3 +610,273 @@ register_auth_provider!(UserPasswordProviderRegistration); // Register the username/password auth data type register_auth_data_type!(UserPasswordAuthDataType); + +#[cfg(test)] +mod tests { + use sha2::{Digest, Sha256}; + + use super::*; + + fn old_unsalted_key_id(username: &str, password: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("user_password:{username}:{password}").as_bytes()); + hex::encode(hasher.finalize()) + } + + /// A provider backed by in-memory storage, with the default length policy + /// (min 8 / max 128). + fn test_provider() -> UserPasswordProvider { + use crate::config::JwtConfig; + use crate::secrets::SecretManager; + use crate::storage::MemoryStorage; + + let storage: Arc = Arc::new(MemoryStorage::new()); + let secret_manager = Arc::new(SecretManager::new(Arc::clone(&storage))); + let token_manager = TokenManager::new( + JwtConfig { + issuer: "calimero-test".to_string(), + access_token_expiry: 3600, + refresh_token_expiry: 30 * 24 * 3600, + }, + Arc::clone(&storage), + secret_manager, + ); + // Constructed directly rather than through `new(ProviderContext, _)`: + // `ProviderContext` carries a full `AuthConfig` the provider never keeps. + UserPasswordProvider { + storage: Arc::clone(&storage), + key_manager: KeyManager::new(storage), + token_manager, + config: UserPasswordConfig::default(), + } + } + + // --- legacy key-id migration (upgrade path) -------------------------- + + #[tokio::test] + async fn legacy_key_id_is_migrated_in_place_on_login() { + // An upgraded node stores the root key under the OLD unsalted id. + // Without migration the salted lookup misses, root keys exist so the + // bootstrap branch is skipped, and the operator is locked out forever. + let provider = test_provider(); + let (user, pass) = ("alice", "correct horse battery staple"); + + let legacy_id = old_unsalted_key_id(user, pass); + let key = Key::new_root_key_with_permissions( + user.to_string(), + "user_password".to_string(), + vec!["admin".to_string()], + None, + ); + provider + .key_manager + .set_key(&legacy_id, &key) + .await + .unwrap(); + + // The existing operator can still log in. + let (key_id, permissions) = provider + .authenticate_core(user, pass) + .await + .expect("an existing user must not be locked out by the key-id change"); + + // ...and they are silently re-keyed onto the salted id. + let new_id = derive_key_id(user, pass); + assert_eq!(key_id, new_id, "login must return the migrated key id"); + assert_eq!(permissions, vec!["admin".to_string()]); + assert!( + provider + .key_manager + .get_key(&new_id) + .await + .unwrap() + .is_some(), + "key must now exist under the salted id" + ); + assert!( + provider + .key_manager + .get_key(&legacy_id) + .await + .unwrap() + .is_none(), + "legacy entry must be removed after migration" + ); + + // A second login resolves directly via the new id. + let (again, _) = provider.authenticate_core(user, pass).await.unwrap(); + assert_eq!(again, new_id); + } + + #[tokio::test] + async fn wrong_password_does_not_migrate_or_authenticate() { + let provider = test_provider(); + let legacy_id = old_unsalted_key_id("alice", "right-password"); + let key = Key::new_root_key_with_permissions( + "alice".to_string(), + "user_password".to_string(), + vec!["admin".to_string()], + None, + ); + provider + .key_manager + .set_key(&legacy_id, &key) + .await + .unwrap(); + + // Wrong password: no legacy key exists for THOSE credentials. + assert!(provider + .authenticate_core("alice", "wrong-password") + .await + .is_err()); + // The real key is untouched. + assert!(provider + .key_manager + .get_key(&legacy_id) + .await + .unwrap() + .is_some()); + } + + // --- password bounds apply to CREATION, not to existing logins ------- + + #[tokio::test] + async fn short_legacy_password_still_authenticates_after_upgrade() { + // The min-length policy must not lock out a user whose password predates + // it (e.g. the `dev`/`dev` credentials every e2e harness uses). + let provider = test_provider(); + let (user, pass) = ("dev", "dev"); // 3 chars, below the min of 8 + let legacy_id = old_unsalted_key_id(user, pass); + let key = Key::new_root_key_with_permissions( + user.to_string(), + "user_password".to_string(), + vec!["admin".to_string()], + None, + ); + provider + .key_manager + .set_key(&legacy_id, &key) + .await + .unwrap(); + + assert!( + provider.authenticate_core(user, pass).await.is_ok(), + "an existing short password must still authenticate" + ); + } + + #[tokio::test] + async fn bootstrap_still_enforces_the_minimum_length() { + // Creating a NEW credential is where the policy bites. + let provider = test_provider(); + let err = provider + .authenticate_core("dev", "dev") + .await + .expect_err("bootstrap with a too-short password must be rejected"); + assert!( + err.to_string().contains("at least"), + "expected a min-length error, got: {err}" + ); + assert!( + provider + .key_manager + .list_keys(crate::storage::models::KeyType::Root) + .await + .unwrap() + .is_empty(), + "no root key may be created when the password violates the policy" + ); + } + + #[tokio::test] + async fn overlong_password_is_rejected_on_the_auth_path() { + // The maximum IS enforced at login: it bounds PBKDF2 work per request. + let provider = test_provider(); + let err = provider + .authenticate_core("alice", &"x".repeat(129)) + .await + .expect_err("an over-long password must be rejected before the KDF runs"); + assert!( + err.to_string().contains("at most"), + "expected a max-length error, got: {err}" + ); + } + + // --- #8: salted KDF key-id derivation ------------------------------- + + #[test] + fn test_derive_key_id_is_deterministic() { + let a = derive_key_id("alice", "correct horse battery staple"); + let b = derive_key_id("alice", "correct horse battery staple"); + assert_eq!(a, b); + assert_eq!(a.len(), KEY_ID_LEN * 2); // hex of 32 bytes + } + + #[test] + fn test_derive_key_id_differs_by_password() { + let a = derive_key_id("alice", "password-one"); + let b = derive_key_id("alice", "password-two"); + assert_ne!(a, b); + } + + #[test] + fn test_derive_key_id_differs_by_username() { + // Same password, different user -> different id (per-user salt). + let a = derive_key_id("alice", "shared-password"); + let b = derive_key_id("bob", "shared-password"); + assert_ne!(a, b); + } + + #[test] + fn test_derive_key_id_is_salted_not_plain_sha256() { + // The new derivation must not equal the old unsalted SHA256. + let username = "alice"; + let password = "correct horse battery staple"; + assert_ne!( + derive_key_id(username, password), + old_unsalted_key_id(username, password) + ); + } + + // --- #8: password length enforcement -------------------------------- + + #[test] + fn test_password_too_short_rejected() { + let err = validate_password_length("short", 8, 128).unwrap_err(); + assert!(err.to_string().contains("at least 8")); + } + + #[test] + fn test_password_too_long_rejected() { + let pw = "x".repeat(129); + let err = validate_password_length(&pw, 8, 128).unwrap_err(); + assert!(err.to_string().contains("at most 128")); + } + + #[test] + fn test_password_within_bounds_accepted() { + assert!(validate_password_length("just-right-pw", 8, 128).is_ok()); + } + + #[test] + fn test_password_length_boundaries_inclusive() { + // Exactly min and exactly max are accepted. + assert!(validate_password_length(&"x".repeat(8), 8, 128).is_ok()); + assert!(validate_password_length(&"x".repeat(128), 8, 128).is_ok()); + } + + #[test] + fn test_password_length_boundaries_exclusive() { + // Exactly min - 1 and exactly max + 1 are rejected. + assert!(validate_password_length(&"x".repeat(7), 8, 128).is_err()); + assert!(validate_password_length(&"x".repeat(129), 8, 128).is_err()); + } + + #[test] + fn test_password_length_counts_unicode_scalars() { + // 8 multi-byte characters should count as length 8, not byte length. + let pw = "áéíóúñçü"; // 8 chars, > 8 bytes + assert_eq!(pw.chars().count(), 8); + assert!(validate_password_length(pw, 8, 128).is_ok()); + } +} diff --git a/crates/auth/src/storage/models/keys.rs b/crates/auth/src/storage/models/keys.rs index 76c6f7d517..c1b02f7568 100644 --- a/crates/auth/src/storage/models/keys.rs +++ b/crates/auth/src/storage/models/keys.rs @@ -98,9 +98,17 @@ impl Key { self.metadata.revoked_at.is_some() } - /// Check if the key is valid (not revoked) + /// Check if the key has expired. + /// + /// A key with no `expires_at` set never expires (returns `false`), + /// preserving the behavior of keys created before expiry was introduced. + pub fn is_expired(&self) -> bool { + self.metadata.is_expired() + } + + /// Check if the key is valid (neither revoked nor expired) pub fn is_valid(&self) -> bool { - !self.is_revoked() + !self.is_revoked() && !self.is_expired() } /// Revoke the key @@ -108,6 +116,26 @@ impl Key { self.metadata.revoke(); } + /// Set an absolute expiry (Unix timestamp, seconds) on this key. + /// + /// `None` clears the expiry, making the key non-expiring. + pub fn set_expires_at(&mut self, expires_at: Option) { + self.metadata.expires_at = expires_at; + } + + /// Builder-style helper that applies a time-to-live (in seconds) to the key, + /// computing an absolute `expires_at` from the current time. + /// + /// A `None` TTL leaves the key non-expiring (current default behavior). + #[must_use] + pub fn with_ttl_secs(mut self, ttl_secs: Option) -> Self { + if let Some(ttl_secs) = ttl_secs { + let now = Utc::now().timestamp().max(0) as u64; + self.metadata.expires_at = Some(now.saturating_add(ttl_secs)); + } + self + } + /// Check if the key has a specific permission pub fn has_permission(&self, required: &str) -> bool { // If key is revoked, no permissions are valid @@ -215,18 +243,57 @@ impl Key { self.node_url.as_deref() } - /// Check if this key is valid for the given node URL + /// Check if this key is valid for the given node URL. + /// + /// Node binding is an **exact host match**: the host parsed from the key's + /// `node_url` must equal the host parsed from the request's `node_url`. + /// A prefix/`starts_with` comparison was previously used, which allowed + /// `node.example.com.attacker.com` to match a key bound to + /// `node.example.com`. This fails closed: if either side cannot be parsed + /// into a host, the key is rejected for that request. pub fn is_valid_for_node(&self, node_url: Option<&str>) -> bool { match (&self.node_url, node_url) { (None, _) => true, // Legacy keys without node_url are valid everywhere (Some(key_node_url), Some(request_node_url)) => { - request_node_url.starts_with(key_node_url) + match (extract_host(key_node_url), extract_host(request_node_url)) { + (Some(key_host), Some(request_host)) => key_host == request_host, + // Fail closed when either host cannot be determined. + _ => false, + } } (Some(_), None) => false, // Node-specific key used without node context } } } +/// Extract the host component from a node URL string. +/// +/// Accepts either a full URL (e.g. `https://node.example.com:8080`) or a bare +/// `host[:port]` authority (e.g. `node.example.com:8080`). Returns the +/// lowercased host with any port stripped, or `None` if no host can be +/// determined. Used for exact node-binding comparisons. +fn extract_host(node_url: &str) -> Option { + if let Ok(url) = url::Url::parse(node_url) { + if let Some(host) = url.host_str() { + return Some(host.to_ascii_lowercase()); + } + } + + // Fall back to treating the value as a bare `host[:port]` authority. + let trimmed = node_url.trim(); + if trimmed.is_empty() { + return None; + } + + let host = trimmed.split('/').next().unwrap_or(trimmed); + let host = host.split(':').next().unwrap_or(host); + if host.is_empty() { + None + } else { + Some(host.to_ascii_lowercase()) + } +} + /// Common metadata for keys #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeyMetadata { @@ -234,6 +301,13 @@ pub struct KeyMetadata { pub created_at: u64, /// When the key was revoked pub revoked_at: Option, + /// When the key expires (Unix timestamp, seconds). + /// + /// `None` means the key never expires. This field defaults to `None` on + /// deserialization so keys persisted before expiry existed still load and + /// remain non-expiring. + #[serde(default)] + pub expires_at: Option, } impl Default for KeyMetadata { @@ -248,6 +322,20 @@ impl KeyMetadata { Self { created_at: Utc::now().timestamp() as u64, revoked_at: None, + expires_at: None, + } + } + + /// Check whether the key has expired relative to the current time. + /// + /// Returns `false` when no expiry is set (non-expiring key). + pub fn is_expired(&self) -> bool { + match self.expires_at { + Some(expires_at) => { + let now = Utc::now().timestamp().max(0) as u64; + now >= expires_at + } + None => false, } } @@ -256,3 +344,143 @@ impl KeyMetadata { self.revoked_at = Some(Utc::now().timestamp() as u64); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn root_key() -> Key { + Key::new_root_key_with_permissions( + "pubkey".to_string(), + "user_password".to_string(), + vec!["admin".to_string()], + None, + ) + } + + fn now_secs() -> u64 { + Utc::now().timestamp().max(0) as u64 + } + + // --- #9: key expiry ------------------------------------------------- + + #[test] + fn test_key_with_no_expiry_is_valid() { + let key = root_key(); + assert!(key.metadata.expires_at.is_none()); + assert!(!key.is_expired()); + assert!(key.is_valid()); + } + + #[test] + fn test_key_not_yet_expired_is_valid() { + let mut key = root_key(); + key.set_expires_at(Some(now_secs() + 3600)); + assert!(!key.is_expired()); + assert!(key.is_valid()); + } + + #[test] + fn test_expired_key_is_invalid() { + let mut key = root_key(); + key.set_expires_at(Some(now_secs().saturating_sub(10))); + assert!(key.is_expired()); + assert!(!key.is_valid()); + } + + #[test] + fn test_with_ttl_secs_sets_future_expiry() { + let key = root_key().with_ttl_secs(Some(3600)); + assert!(key.metadata.expires_at.is_some()); + assert!(!key.is_expired()); + assert!(key.is_valid()); + } + + #[test] + fn test_with_ttl_secs_none_is_non_expiring() { + let key = root_key().with_ttl_secs(None); + assert!(key.metadata.expires_at.is_none()); + assert!(!key.is_expired()); + } + + #[test] + fn test_expires_at_defaults_to_none_on_deserialize() { + // Legacy persisted metadata without an `expires_at` field must still + // load and remain non-expiring. + let legacy = r#"{"created_at": 1000, "revoked_at": null}"#; + let meta: KeyMetadata = serde_json::from_str(legacy).unwrap(); + assert!(meta.expires_at.is_none()); + assert!(!meta.is_expired()); + } + + #[test] + fn test_legacy_key_json_without_expiry_loads() { + let legacy = r#"{ + "key_type": "Root", + "public_key": "pubkey", + "auth_method": "user_password", + "root_key_id": null, + "name": null, + "permissions": ["admin"], + "created_at": 1000, + "revoked_at": null + }"#; + let key: Key = serde_json::from_str(legacy).unwrap(); + assert!(key.metadata.expires_at.is_none()); + assert!(key.is_valid()); + + // Full serde round-trip: a legacy key re-serialized and re-loaded must + // stay non-expiring and valid. + let json = serde_json::to_string(&key).unwrap(); + let key: Key = serde_json::from_str(&json).unwrap(); + assert!(key.metadata.expires_at.is_none()); + assert!(!key.is_expired()); + assert!(key.is_valid()); + } + + // --- #11: node-host binding exact match, fail closed ---------------- + + #[test] + fn test_node_binding_rejects_suffix_attack() { + let mut key = root_key(); + key.node_url = Some("https://node.example.com".to_string()); + assert!(!key.is_valid_for_node(Some("https://node.example.com.attacker.com"))); + } + + #[test] + fn test_node_binding_exact_match_accepted() { + let mut key = root_key(); + key.node_url = Some("https://node.example.com".to_string()); + assert!(key.is_valid_for_node(Some("https://node.example.com"))); + } + + #[test] + fn test_node_binding_exact_match_ignores_port() { + let mut key = root_key(); + key.node_url = Some("https://node.example.com".to_string()); + assert!(key.is_valid_for_node(Some("https://node.example.com:8443"))); + } + + #[test] + fn test_node_binding_absent_request_node_rejected() { + let mut key = root_key(); + key.node_url = Some("https://node.example.com".to_string()); + assert!(!key.is_valid_for_node(None)); + } + + #[test] + fn test_node_binding_legacy_key_valid_everywhere() { + let key = root_key(); + assert!(key.node_url.is_none()); + assert!(key.is_valid_for_node(Some("https://anything.example.com"))); + assert!(key.is_valid_for_node(None)); + } + + #[test] + fn test_node_binding_bare_host_authority() { + let mut key = root_key(); + key.node_url = Some("node.example.com".to_string()); + assert!(key.is_valid_for_node(Some("node.example.com:8443"))); + assert!(!key.is_valid_for_node(Some("node.example.com.attacker.com"))); + } +} diff --git a/scripts/e2e-auth-seam.sh b/scripts/e2e-auth-seam.sh index 1fa0dab6d3..6066b505cd 100755 --- a/scripts/e2e-auth-seam.sh +++ b/scripts/e2e-auth-seam.sh @@ -26,7 +26,9 @@ set -eu NODE_URL="${1:-http://localhost:4001}" USERNAME="${MERO_E2E_USER:-dev}" -PASSWORD="${MERO_E2E_PASS:-dev}" +# Must satisfy the provider's configured minimum length (default 8) — the +# bootstrap path enforces it for every NEW credential. +PASSWORD="${MERO_E2E_PASS:-dev-password}" # The exact permission strings mero-react demands for AppMode.MultiContext # (getPermissionsForMode) and auth-frontend forwards untouched.