Skip to content
Draft
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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ From a fresh clone:
docker compose up --build
```

The Compose entrypoint generates and persists a random creator-authority encryption
The Compose entrypoint generates and persists a random runtime master
key in the private `lock-home` volume. Set
`PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY` before startup only when you need to supply
your own 32-byte base64url key.
`PUBKY_LOCK_RUNTIME_MASTER_KEY` before startup only when you need to supply
your own 32-byte unpadded-base64url key. A supplied override is atomically
persisted to that volume, so a later startup without the environment variable
continues using the same key rather than silently reverting to an older key.

Verified browser-facing defaults are:

Expand Down Expand Up @@ -425,7 +427,8 @@ Example:
"params": {
"recipient_pubky": "pubky<creator_z32>",
"amount": "50000",
"asset": "BTC"
"asset": "BTC",
"payment_in": 24
}
}
],
Expand Down Expand Up @@ -458,7 +461,7 @@ For example:

A submitted proof bundle is sent by the viewer before verification. It is not stored as an entitlement unless verification succeeds.

For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, and non-empty `asset`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice.
For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, non-empty `asset`, and positive whole-hour JSON `u64` `payment_in`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice.

Example:

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ services:
network_mode: service:pubky-testnet
environment:
PUBKY_LOCK_DATABASE_URL: postgres://locks:locks@postgres:5432/locks_test
PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY: ${PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY:-}
PUBKY_LOCK_RUNTIME_MASTER_KEY: ${PUBKY_LOCK_RUNTIME_MASTER_KEY:-}
volumes:
- lock-home:/var/lib/pubky-lock
command: ["locks-server-compose-entrypoint.sh"]
Expand Down
74 changes: 65 additions & 9 deletions docker/locks-server-compose-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,74 @@ service_home="${LOCKS_SERVICE_HOME:-/var/lib/pubky-lock/.pubky-lock}"
generated_config="$service_home/config.toml"
compose_config="${LOCKS_COMPOSE_CONFIG:-/var/lib/pubky-lock/config.compose.toml}"
secret_path="$service_home/secret.sess"
creator_authority_key_path="$service_home/creator-authority-encryption-key"
runtime_master_key_path="$service_home/runtime-master-key"
retired_creator_authority_key_path="$service_home/creator-authority-encryption-key"

mkdir -p "$service_home"

if [ -z "${PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY:-}" ]; then
if [ ! -f "$creator_authority_key_path" ]; then
echo "[locks-compose] generating creator-authority encryption key"
if [ -f "$retired_creator_authority_key_path" ]; then
echo "[locks-compose] retired creator-authority key detected: $retired_creator_authority_key_path" >&2
echo "[locks-compose] stop the stack, discard and reacquire creator authority rows or recreate the local database, remove the retired key file, then restart" >&2
exit 1
fi

if [ -n "${PUBKY_LOCK_RUNTIME_MASTER_KEY:-}" ]; then
if ! printf '%s' "$PUBKY_LOCK_RUNTIME_MASTER_KEY" | grep -Eq '^[A-Za-z0-9_-]{43}$'; then
echo "[locks-compose] PUBKY_LOCK_RUNTIME_MASTER_KEY must be an unpadded base64url-encoded 32-byte key" >&2
exit 1
fi
umask 077
decoded_key_path="$runtime_master_key_path.decoded.$$"
cleanup_decoded_key() {
rm -f "$decoded_key_path"
}
trap cleanup_decoded_key EXIT HUP INT TERM
if ! printf '%s=' "$PUBKY_LOCK_RUNTIME_MASTER_KEY" \
| tr '_-' '/+' \
| base64 -d > "$decoded_key_path" 2>/dev/null \
|| [ "$(wc -c < "$decoded_key_path" | tr -d ' ')" -ne 32 ]; then
echo "[locks-compose] PUBKY_LOCK_RUNTIME_MASTER_KEY must be an unpadded base64url-encoded 32-byte key" >&2
exit 1
fi
canonical_runtime_master_key="$(
base64 < "$decoded_key_path" \
| tr '+/' '-_' \
| tr -d '=\n'
)"
if [ "$canonical_runtime_master_key" != "$PUBKY_LOCK_RUNTIME_MASTER_KEY" ]; then
echo "[locks-compose] PUBKY_LOCK_RUNTIME_MASTER_KEY must be an unpadded base64url-encoded 32-byte key" >&2
exit 1
fi
cleanup_decoded_key
trap - EXIT HUP INT TERM
if [ -f "$runtime_master_key_path" ]; then
persisted_runtime_master_key="$(cat "$runtime_master_key_path")"
if [ "$persisted_runtime_master_key" != "$PUBKY_LOCK_RUNTIME_MASTER_KEY" ]; then
echo "[locks-compose] PUBKY_LOCK_RUNTIME_MASTER_KEY does not match the persisted runtime master key" >&2
echo "[locks-compose] rotate only through an explicit data migration or reset that handles encrypted state" >&2
exit 1
fi
else
temporary_key_path="$runtime_master_key_path.tmp.$$"
printf '%s' "$PUBKY_LOCK_RUNTIME_MASTER_KEY" > "$temporary_key_path"
chmod 600 "$temporary_key_path"
mv "$temporary_key_path" "$runtime_master_key_path"
fi
else
if [ ! -f "$runtime_master_key_path" ]; then
echo "[locks-compose] generating runtime master key"
umask 077
temporary_key_path="$creator_authority_key_path.tmp.$$"
temporary_key_path="$runtime_master_key_path.tmp.$$"
head -c 32 /dev/urandom \
| base64 \
| tr '+/' '-_' \
| tr -d '=\n' > "$temporary_key_path"
chmod 600 "$temporary_key_path"
mv "$temporary_key_path" "$creator_authority_key_path"
mv "$temporary_key_path" "$runtime_master_key_path"
fi

PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY="$(cat "$creator_authority_key_path")"
export PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY
PUBKY_LOCK_RUNTIME_MASTER_KEY="$(cat "$runtime_master_key_path")"
export PUBKY_LOCK_RUNTIME_MASTER_KEY
fi

if [ ! -f "$generated_config" ] || [ ! -f "$secret_path" ]; then
Expand Down Expand Up @@ -78,7 +127,14 @@ frontend_session_code_ttl_seconds = 120
allowed_return_origins = ["http://localhost:8080"]

[secrets]
creator_authority_key_env = "PUBKY_LOCK_CREATOR_AUTH_ENCRYPTION_KEY"
runtime_master_key_env = "PUBKY_LOCK_RUNTIME_MASTER_KEY"

[deletion]
retry_max_attempts = 10
retry_initial_backoff_seconds = 1
retry_max_backoff_seconds = 300
final_credential_issuance_window_seconds = 900
final_read_window_seconds = 900

[logging]
level = "info"
Expand Down
24 changes: 13 additions & 11 deletions docs/ADRs/0020-locks-paykit-v1-integration-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ The v1 content-lock criterion has verifier wire value `paykit-payment` and param
{
"recipient_pubky": "pubky<creator>",
"amount": "50000",
"asset": "BTC"
"asset": "BTC",
"payment_in": 24
}
```

- `recipient_pubky` must equal the canonical content-lock creator.
- `amount` is a positive decimal integer string in the asset's base unit.
- `asset` is an opaque, non-empty string to Locks. Paykit Server owns deployment-specific asset support and base-unit interpretation.
- `payment_in` is a required, nonzero JSON `u64` number of whole hours in Locks policy.
- V1 permits exactly one payment criterion, referenced exactly once by the lock logic, and exactly one submitted payment proof.
- The submitted payment proof payload is `{}`. `reader_public_key` is top-level submission data.
- Content-lock authoring does not require runtime Paykit configuration or availability.
Expand All @@ -44,14 +46,14 @@ The v1 content-lock criterion has verifier wire value `paykit-payment` and param

1. apply rate limiting;
2. validate proof shape;
3. load and validate the current canonical Lock Resource and payment policy;
4. resolve the current reader through Pubky discovery;
5. compare any persisted lifecycle under `{ creator, bundle_id }`;
6. return an exact persisted replay or reject changed submitted proof material with `409 task_state_conflict`;
7. require configured Paykit and create an invoice only for a new identity; and
8. insert the verification task with post-invoice race reconciliation.
3. compare any durable lifecycle or admission reservation under `{ creator, bundle_id }` before mutable Lock Resource lookup or reader discovery;
4. return an exact ready replay, resume an exact unready reservation from its persisted submission fields, or reject changed submitted proof material with `409 task_state_conflict`;
5. only for a genuinely new identity, load and validate the current canonical Lock Resource and payment policy and resolve the current reader through Pubky discovery;
6. require configured Paykit and atomically persist a hidden, unclaimable admission reservation under the per-lock deletion/admission fence;
7. create or idempotently replay the Paykit invoice; and
8. mark the reservation ready so the verification task becomes publicly visible and worker-claimable.

Exact and changed persisted replays do not call Paykit. Terminal lifecycle state is not restarted under the same identity; clients needing another attempt must generate a new Bundle ID.
Exact ready replay does not call Paykit. Exact unready replay requires configured Paykit and repeats the same persisted idempotent invoice request before marking the reservation ready. Changed replay conflicts without calling Paykit. Terminal lifecycle state is not restarted under the same identity; clients needing another attempt must generate a new Bundle ID.

### Invoice request

Expand All @@ -67,7 +69,7 @@ For a new lifecycle identity, Locks sends RFC 8785 canonical JSON to `POST /invo

Locks signs the exact canonical body bytes with its existing Ed25519 keypair and sends the unpadded-base64url signature in `X-Paykit-Signature`.

The durable Paykit invoice identity is `(creator, bundle_id)`, where Paykit derives `creator` from `lock_resource`. Exact replay must return the original generic success without repeating mutable lookups, allocation, address creation, or delivery side effects. A different binding under the same identity returns Paykit `409 Conflict`, which Locks maps to `409 task_state_conflict`. Locks accepts any Paykit 2xx response and ignores its body; other invoice failures return `502 paykit_invoice_creation_failed` without creating a new verification task.
The durable Paykit invoice identity is `(creator, bundle_id)`, where Paykit derives `creator` from `lock_resource`. Exact replay must return the original generic success without repeating mutable lookups, allocation, address creation, or delivery side effects. A different binding under the same identity returns Paykit `409 Conflict`, which Locks maps to `409 task_state_conflict`. Locks accepts any Paykit 2xx response and ignores its body; other invoice failures return `502 paykit_invoice_creation_failed` while the internal Locks reservation remains hidden and unclaimable for exact retry.

### Status request and access policy

Expand All @@ -92,7 +94,7 @@ V1 has no invoice expiry, TTL, `expires_at`, or terminal Paykit payment-failure

### Runtime boundary

- A new payment lifecycle requires `[paykit]`; exact persisted replay does not.
- A new payment lifecycle and exact unready reconciliation require `[paykit]`; exact ready replay does not.
- Paykit HTTP connect timeout is 5 seconds and whole-request timeout is 20 seconds.
- An enabled in-process Paykit worker requires `claim_timeout_seconds > 20`.
- `worker.poll_interval_ms` must be greater than zero whether the worker is enabled or disabled.
Expand All @@ -112,7 +114,7 @@ V1 has no invoice expiry, TTL, `expires_at`, or terminal Paykit payment-failure

- Both services must implement the same canonical-body signing contract.
- Paykit must parse the public Locks payment criterion and therefore depends on its versioned shape.
- Exact submission replay intentionally performs current canonical lock and reader preflight before returning persisted lifecycle state.
- Incomplete admission reservations require durable reconciliation before deletion may start the Paykit drain.
- Unpaid invoices and pending Locks tasks have no protocol expiry in v1 and therefore require operational retention policy outside the payment-status contract.

## Rejected alternatives
Expand Down
Loading