Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .build/traefik/http.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ http:
servers:
- url: 'http://host.docker.internal:3001/'

trust-gateway:
loadBalancer:
servers:
- url: 'http://trust-gateway:8080/'

hydra:
loadBalancer:
servers:
Expand Down Expand Up @@ -234,6 +239,13 @@ http:
trustForwardHeader: true

routers:
trust-gateway-callback:
rule: 'Method(`GET`) && Path(`/oauth/cleverbase/callback`)'
service: 'trust-gateway'
entryPoints:
- 'web'
priority: 200

oidc-public:
rule: 'PathPrefix(`/oidc`)'
service: 'oidc-service'
Expand Down Expand Up @@ -449,6 +461,15 @@ http:
- 'web'
priority: 150

content-signing-snapshot:
rule: 'Method(`GET`) && PathPrefix(`/api/private/rest/content-signing/`)'
service: 'alkemio-server'
middlewares:
- strip-api-private-prefix
entryPoints:
- 'web'
priority: 150

kratos-public:
rule: 'PathPrefix(`/ory/kratos/public`)'
service: 'kratos-public'
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ jobs:
path: coverage-ci/
retention-days: 1

content-signing-integration:
runs-on: ubuntu-latest
permissions:
contents: read
env:
NODE_OPTIONS: "--max-old-space-size=4096"
steps:
- uses: actions/checkout@v7

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.22.0'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Checkout pinned file-service for content-signing integration
uses: actions/checkout@v7
with:
repository: alkem-io/file-service
ref: 0a3995b235ef427c9d7cfd1092e7945e5244c137
path: .content-signing-file-service

- name: Run content-signing real-service integration tests
run: |
docker build --label org.opencontainers.image.source=https://github.com/alkem-io/file-service --label org.opencontainers.image.revision=0a3995b235ef427c9d7cfd1092e7945e5244c137 --tag aiai2025-file-service-pr1:0a3995 .content-signing-file-service
CONTENT_SIGNING_FILE_SERVICE_IMAGE=aiai2025-file-service-pr1:0a3995 pnpm run test:content-signing:coverage

- name: Upload content-signing coverage artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-content-signing
path: coverage-ci/content-signing/
retention-days: 1

sonarqube:
needs: test
runs-on: ubuntu-latest
Expand Down
45 changes: 45 additions & 0 deletions .scripts/content-signing/test-real-services.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(cd "${script_dir}/../.." && pwd)"
compose_file="${repo_dir}/test/integration/content-signing/compose.yml"
project_name="content-signing-${PPID}-${RANDOM}"

export CONTENT_SIGNING_DB_PORT="${CONTENT_SIGNING_DB_PORT:-55426}"
export CONTENT_SIGNING_FILE_SERVICE_PORT="${CONTENT_SIGNING_FILE_SERVICE_PORT:-44003}"
export CONTENT_SIGNING_FILE_SERVICE_IMAGE="${CONTENT_SIGNING_FILE_SERVICE_IMAGE:-aiai2025-file-service-pr1@sha256:fdd302dd8c3f1a272d7215237aec5bc246edcdaf52cce33193b79be968c55144}"
expected_file_service_revision="0a3995b235ef427c9d7cfd1092e7945e5244c137"

image_revision="$(docker image inspect "${CONTENT_SIGNING_FILE_SERVICE_IMAGE}" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')"
if [[ "${image_revision}" != "${expected_file_service_revision}" ]]; then
echo "file-service image revision mismatch: expected ${expected_file_service_revision}, got ${image_revision:-<none>}" >&2
exit 1
fi
cleanup() {
docker compose --project-name "${project_name}" --file "${compose_file}" down --volumes --remove-orphans
}
trap cleanup EXIT

docker compose --project-name "${project_name}" --file "${compose_file}" up --detach --wait

for _ in $(seq 1 30); do
if curl --fail --silent "http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}/health" >/dev/null; then
break
fi
sleep 1
done
curl --fail --silent "http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}/health" >/dev/null

export CONTENT_SIGNING_REAL_SERVICES=true
export CONTENT_SIGNING_DB_HOST=127.0.0.1
export CONTENT_SIGNING_DB_NAME=content_signing
export CONTENT_SIGNING_DB_USER=content_signing
export CONTENT_SIGNING_DB_PASSWORD=content_signing
export CONTENT_SIGNING_FILE_SERVICE_URL="http://127.0.0.1:${CONTENT_SIGNING_FILE_SERVICE_PORT}"

cd "${repo_dir}"
if ! pnpm exec vitest run test/integration/content-signing/signing-attempt.postgres.spec.ts "$@"; then
docker compose --project-name "${project_name}" --file "${compose_file}" logs file-service
exit 1
fi
4 changes: 4 additions & 0 deletions alkemio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ search:
# uploaded can always be parsed. Raising it above that has no effect.
collabora_document_max_source_size: ${SEARCH_COLLABORA_DOCUMENT_MAX_SOURCE_SIZE}:15728640

trustGateway:
# Local host-run default; deployments must set TRUST_GATEWAY_URL to the cluster Service.
url: ${TRUST_GATEWAY_URL}:http://localhost:8080

licensing:
wingback:
enabled: ${LICENSING_WINGBACK_ENABLED}:false
Expand Down
124 changes: 124 additions & 0 deletions docs/local-content-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Local memo signing

This fixture uses synthetic keys and an RFC 3161 TSA. It proves Alkemio wiring and B-T PDF
integrity, not real Cleverbase subject equivalence, certificate trust, revocation or QES status.
The host and the existing development stack are the local trust boundary: the gateway and mock
publish only on loopback, but sibling containers share `alkemio_dev_net`.

## Start the fixture

The Compose file pins trust-gateway v0.2.0 and the Cleverbase reference mock by digest. Start the
normal quickstart with fresh storage, then run the server and client on their usual host ports.
Docker, pnpm and `jq` are prerequisites. `COMPOSE_PROJECT_NAME` names only this task-owned fresh
stack; never run the volume-removal command against a default or developer project.

```bash
export COMPOSE_PROJECT_NAME=aiai-2025-content-signing
docker compose -p "$COMPOSE_PROJECT_NAME" -f quickstart-services.yml \
--env-file .env.docker down -v --remove-orphans
pnpm start:services
pnpm migration:run
pnpm start:dev
```

In a second terminal, from the client-web feature checkout, start the host client on port 3001:

```bash
pnpm start
```

The host-run server uses `http://localhost:8080`; Traefik routes only exact GET requests for
`/oauth/cleverbase/callback` to the gateway. `/v1/sign/*` has no Traefik route. Check both pinned
containers before using the UI:

```bash
curl -fsS http://127.0.0.1:8080/readyz
curl -fsS http://127.0.0.1:9000/healthz
```

## Link the local admin identity

Import the mock subject through Kratos's normal admin identity API. The update explicitly
re-imports the local password credential, so browser login remains available. Run this only on a
fresh local Kratos database; it is not a production identity bypass.

```bash
export SIGNING_EMAIL=admin@alkem.io SIGNING_PASSWORD=password
export KRATOS_ADMIN=http://localhost:3000/ory/kratos/admin
identity_id=$(curl -fsS --get "$KRATOS_ADMIN/identities" \
--data-urlencode "credentials_identifier=$SIGNING_EMAIL" | jq -er '.[0].id')
identity=$(curl -fsS "$KRATOS_ADMIN/identities/$identity_id")
body=$(jq --arg password "$SIGNING_PASSWORD" '
{schema_id,state,traits,metadata_admin,metadata_public,
credentials:{password:{config:{password:$password}},
oidc:{config:{providers:[{provider:"cleverbase",subject:"PNONL-123"}]}}}}' \
<<<"$identity")
curl -fsS -X PUT "$KRATOS_ADMIN/identities/$identity_id" \
-H 'Content-Type: application/json' --data "$body" >/dev/null
curl -fsS "$KRATOS_ADMIN/identities/$identity_id?include_credential=oidc" |
jq -e '.credentials.password != null and
(.credentials.oidc.identifiers | index("cleverbase:PNONL-123") != null)'
```

`PNONL-123` is the provider subject from the
[SDK mock-signer contract](https://github.com/alkem-io/cleverbase-sdk/blob/develop/examples/reference-integration/mock-upstream/README.md),
not the X.509 certificate serial. The complete environment recipe is owned by the
[gateway v0.2.0 local-stack documentation](https://github.com/alkem-io/trust-gateway/blob/v0.2.0/README.md#local-alkemio-stack-mock-and-public-stub).

## Verify the journey

Log in at `http://localhost:3000/login` with the seeded local admin, open a memo and select **Sign**.
Record these checks:

1. Unsaved collaboration changes become durable before prepare; the same-origin preview iframe
shows the exact PDF with `Content-Disposition: inline`, no `X-Frame-Options: DENY`, and no
`frame-ancestors 'none'` on the response.
2. Continue traverses both mock authorization redirects and returns through
`/api/public/rest/content-signing/complete`; the final memo URL contains only
`signingAttemptId=<uuid>` for the signing outcome.
3. Download the signed PDF and run `pdfsig <file.pdf>`: the signature is valid and the timestamp is
present. The UI's **Recorded** value is the server `updatedDate` and stays unchanged on reload.
4. Sign the memo again: a second copy is appended and the first is unchanged.
5. For login restoration, log out before following the terminal gateway return, then log in again;
the original REST return URL completes and redirects to the memo.
6. For decline, copy the first mock authorization URL from the browser before following it, then
run the commands below. The attempt becomes cancelled without a signed document.
7. For natural expiry, create and continue another attempt, copy its ID, then leave it uncompleted:
do not follow its authorization URL or complete its callback. Read the gateway's authoritative
`expiresAt` from the persisted attempt as shown below. After that instant, the one-minute expiry
margin and the next hourly sweep, the actor-bound query reports `EXPIRED` without an attached
result; this is not an immediate-expiry check.

Read either terminal state through the actor-bound GraphQL query. Copy the session cookie request
header from the browser devtools into the local shell without committing or printing it:

```bash
export SIGNING_ATTEMPT_ID='<attempt UUID from the memo return URL or prepare response>'
export ALKEMIO_SESSION_COOKIE='<browser Cookie request header>'
read_attempt() {
jq -nc --arg id "$SIGNING_ATTEMPT_ID" \
'{query:"query($id: UUID!) { signingAttempt(ID: $id) { id status } }",variables:{id:$id}}' |
curl -fsS http://localhost:3000/graphql -H 'Content-Type: application/json' \
-H "Cookie: $ALKEMIO_SESSION_COOKIE" --data-binary @- |
jq -e '.data.signingAttempt | {id,status}'
}

authorize_url='<first mock authorization URL copied from the browser>'
state=$(jq -nr --arg url "$authorize_url" '$url | capture("[?&]state=(?<value>[^&]+)").value')
curl -fsS -o /dev/null -D - \
"http://localhost:3000/oauth/cleverbase/callback?state=$state&error=access_denied"
read_attempt

# Prepare and continue a new attempt, do not visit its authorization URL, then set its ID here.
export SIGNING_ATTEMPT_ID='<abandoned continued attempt UUID>'
read_attempt
expires_at=$(docker compose -p "$COMPOSE_PROJECT_NAME" -f quickstart-services.yml \
--env-file .env.docker exec -T postgres psql -U synapse -d alkemio -Atc \
"SELECT \"expiresAt\" FROM signing_attempt WHERE id = '$SIGNING_ATTEMPT_ID'")
printf 'wait until after %s + 1 minute, then allow up to one hour for the sweep\n' "$expires_at"
# After that bounded wait:
read_attempt
```

Live Cleverbase needs the real client/TSA credentials and a confirmed subject mapping supplied out
of band. It uses no mock container and must not place credentials in this repository.
Loading
Loading