Pubky Locks is a pre-production content-gating application for the Pubky ecosystem. Creators publish lock policies for guarded homeserver resources; viewers satisfy the criteria and receive scoped access through a Lock Server.
Warning: Interfaces, persistence formats, and deployment assumptions may change without migration support. Do not use this repository to protect valuable private content or real funds without an independent security and operational review.
locks-core— protocol and domain typeslocks-service— application workflows and persistence adapterslocks-server— HTTP server, worker, configuration, and runtime compositionlocks-sdk— native creator/viewer SDKlocks-sdk/bindings/js— browser JS/WASM bindingslocks-e2e— integration and PostgreSQL-backed testsexamples/js-sdk— local creator and reader browser demos
The repository uses Rust 1.89.0. CI also requires PostgreSQL 16, Node.js 22,
cargo-nextest, the wasm32-unknown-unknown target, and wasm-pack 0.13.1.
cargo fmt --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --no-run
npm --prefix locks-sdk/bindings/js run testSee CONTRIBUTING.md for the exact test taxonomy and
.github/workflows/check.yml for PostgreSQL/E2E gates.
The integrated Docker Compose flow starts PostgreSQL, a local Pubky testnet, the Lock
Server, and separate creator/reader browser demos. Follow the copy-pasteable setup in
examples/js-sdk/README.md.
Verified browser-facing defaults are:
- Lock Server: http://localhost:3000
- creator demo: http://localhost:8080/examples/js-sdk/
- reader demo: http://localhost:8081/reader/
- Lock Server API
- Lock Server Runtime
- Browser SDK
- Domain model
- Terminology
- Local operator demo
- Security policy
- Support
The remainder of this document records the original draft proposal and its security trade-offs. Current route and runtime documentation above is authoritative where the proposal differs from the implementation.
Proposal version: 0.5 Proposal date: May 7, 2026 Status: Draft
Locks is the content gating application for the Pubky ecosystem. Content creators attach criteria like payment, password, follower status, time window, limited seats, or other future lock types to their content. Viewers receive access to the guarded content by satisfying those criteria.
In this proposal, Locks is intentionally an application-layer mechanism. It does not require homeservers to understand payment logic, subscriptions, passwords, or any other lock-specific verification. Homeservers continue to enforce Pubky sessions and path-scoped capabilities; the Lock Server adds conditional access logic on top.
This proposal does not define how every lock type is verified. Each lock type can be added separately, and its verification will depend on the nature of that lock.
This proposal also does not specify the exact secure communication method between Viewer App and Lock Server. The security of that communication is out of scope because it can be handled with existing secure client-server communication methods.
The paths, payloads, and endpoint shapes in this document are suggestions and examples. They may change as the implementation and capability model evolve.
Design of this proposal was created with the following goals:
- Enable lock functionality with minimum disturbance of existing Pubky stack elements
- Keep implementation simple, with clear trade-offs and known paths for mitigation
- Deliver Locks into the Pubky ecosystem without changing existing trust assumptions
- Keep Locks compatible with Pubky's credible-exit narrative
- Support viewers who may or may not have Pubky identities
- Individual items behind a paywall
- All items behind a subscription
- A password-protected item
- An item available for a limited time, relative or absolute
- An item available to a limited number of users, for example first 10 users can access
- An item available only to followers
Locks Server is a Pubky ecosystem application from the perspective of permissions and authentication. It receives creator-granted capabilities and uses a homeserver-issued session/JWT to read and, where needed, write under creator-owned paths. From the traditional web point of view, it is a server which may or may not have a user interface. A user interface is desired for human-readable representation of lock file content.
For the implemented authorization boundary, see ADR 0017.
Content creator is a Pubky user who creates content they want to put behind a lock.
Content viewer is a user or non-user who is able to provide satisfactory proof for a lock's criteria. A viewer does not necessarily have a Pubky identity. Some lock types may require viewer identity; others may support anonymous viewers.
Content lock is structured public data that specifies the criteria for accessing guarded content.
Submitted proof bundle is structured data sent by a viewer to the Lock Server. It contains information required to verify that the viewer satisfies the lock criteria.
Verified proof bundle is a submitted proof bundle after successful verification. It is stored by the Lock Server under the creator's guarded Locks storage. A stored verified proof bundle functions as an entitlement record.
Bundle ID is a cryptographically random identifier used as the final filename for the stored verified proof bundle. It is a bearer secret. The viewer is responsible for storing it for future reference, for example after Lock Server migration.
Access credential is the still-to-be-finalized token, URL, session, or other credential issued by the Lock Server after successful verification or after resolving an existing verified proof bundle.
Lock Server as a Pubky application can optionally provide functionality for content creator to:
- Create guarded content on the creator's homeserver
- Create public lock policies under
/pub/locks.app/* - Store verified proof bundles under a guarded Locks path
Alternatively, content creators can create guarded content and public lock policies using other available methods.
Lock Server as a Pubky application provides functionality for content viewer to:
- Read public lock conditions
- Accept and verify viewer proof bundles
- Store verified proof bundles as entitlement records after successful verification
- Issue an access credential
- Proxy-read guarded content from
pubky<creator_z32>/priv/<content_id>and proxy-pass the response to the viewer - Optionally provide UI for representation of public lock conditions
The Lock Server requires creator-granted capabilities according to the role it performs.
Minimum for verification plus proxy access:
- Read the guarded content selected by Locks
- Write verified proof bundles under a Locks-controlled guarded path
A future narrower target could look like:
[/priv/locks.app/content/:r, /priv/locks.app/proofs/:rw]
If the Lock Server also creates guarded content and public lock policies for the creator, it additionally needs write access to the guarded content namespace and:
[/pub/locks.app/:rw]
Until narrower guarded capabilities are implemented, the interim grant may need to be broader, for example:
[/priv/:rw, /pub/locks.app/:rw]
This trade-off should be explicit: a broad /priv/:rw grant means the creator is trusting the Lock Server with all guarded payloads, not only Locks-specific payloads. The desired end state is a Locks-specific private namespace.
The Lock Server is addressed as a Pubky resource, which also makes it resolvable as an HTTPS service through Pubky/Pkarr resolution.
Canonical Pubky resource form, example:
pubky<lock_server_z32>/<creator_z32>/unlock/<lock_id>
Resolved HTTPS transport form, example:
https://_pubky.<lock_server_z32>/<creator_z32>/unlock/<lock_id>
The exact transport spelling should follow the current Pubky resource-addressing conventions. The important distinction is that the lock policy should treat the Lock Server as a Pubky-addressed service first, with HTTPS as the resolved transport.
The Lock Server has its own Pkarr record, like other Pubky-addressed services.
Example, subject to final Pubky/Pkarr conventions:
@ A <IP>
@ HTTPS <domain name>
For credible exit, the default Lock Server pointer should be centralized rather than duplicated into every lock policy.
Recommended default location:
/pub/locks.app/config.json
Current v0 Lock Service Pointer shape:
{
"version": 1,
"default_lock_server": "pubky<lock_server_z32>",
"created_at": "2026-05-07T00:00:00Z"
}The current local skeleton authors this object through dev/test-gated POST /creator/lock-service-config; it stores local in-memory state only and does not perform a Pubky homeserver write. A content lock may optionally override the default service when needed:
{
"lock_server": { "override": "pubky<other_lock_server_z32>" }
}Per-lock service URLs are useful as an escape hatch, but should not be the default migration mechanism because they make service migration O(<number of locks>).
A more risky alternative is to store the Locks service pointer in the user's Pkarr record, for example:
_pubky HTTPS <homeserver public key>
_locks HTTPS <lock server public key>
This needs more careful consideration and should not be the default in this draft.
- Content creator authorizes the Lock Server or creator app with the required capabilities.
- Content creator uploads guarded content to their homeserver, preferably under a Locks-specific private namespace once supported.
- Content creator computes a hash of the guarded content.
- Content creator defines lock conditions and uploads the public lock policy to
/pub/locks.app/<lock_id>.json. - Content creator creates a preview post anywhere, such as a pubky.app post, pointing to the public lock policy.
Example preview text:
Check out my locked content at pubky<creator_z32>/pub/locks.app/<lock_id>.json
Guarded content write should not trigger a public /events entry. Public lock policy write should trigger /events.
Current implementation note: authenticated Pubky-backed creator publishing routes are implemented for PUT /creator/priv-resources/content/<path>, DELETE /creator/priv-resources/content/<path>, POST /creator/content-locks, and POST /creator/lock-service-config. They derive creator identity from a Locks-local frontend session and use Pubky homeserver repositories in production/dev integration composition; local-memory repositories remain test-support only. See docs/API.md for the current HTTP route contract, docs/LOCAL_DEMO.md for the client-backed local flow, docs/RUNTIME.md for runtime/operator behavior, and docs/LOCAL_OPERATOR_DEMO.md for a manual local HTTP walkthrough.
sequenceDiagram
participant C as Content Creator App
participant H as Homeserver
Note over C,H: Step 1: Authorize with Grant
C->>H: POST /session
H-->>C: 200 JWT
Note over C,H: Step 2: Store guarded payload
C->>H: PUT /priv/locks.app/content/<content_id> [header: JWT]
H-->>C: 200 OK
Note over C,C: Step 3: Hash guarded payload
C->>C: blake3(content)
Note over C,H: Step 4: Create lock policy
C->>H: PUT /pub/locks.app/<lock_id>.json [header: JWT]
H-->>C: 200 OK
H-->>H: Emit /events
Note over C,H: Step 5: Publish preview
C->>H: PUT /pub/<app-id>/posts/<id> [header: JWT]
H-->>C: 200 OK
H-->>H: Emit /events
- Viewer discovers lock either through a preview post or via
/eventsendpoint. Event discovery may be missing human context. - Viewer reads public unlock conditions from
pubky<creator_z32>/pub/locks.app/<lock_id>.json. - Viewer resolves the Lock Server using
/pub/locks.app/config.json, unless the lock policy contains a service override. - Viewer solves the lock-specific challenge or gathers required proof material.
- Viewer submits a proof bundle to the Lock Server. The current HTTP server applies a configurable process-local fixed-window admission limit before creating verification work.
- Lock Server verifies the submitted proof bundle.
- If verification fails, no entitlement is stored.
- If verification succeeds, Lock Server stores the verified proof bundle under the creator's guarded Locks proof path using
bundle_idas the final filename. - Lock Server returns an access credential or access URL to the viewer.
- Viewer requests guarded content from the Lock Server using the access credential.
- Lock Server proxy-gets the guarded content from the creator homeserver using its own creator-authorized JWT.
- Lock Server proxy-passes the response to the viewer.
sequenceDiagram
participant V as Viewer App
participant L as Lock Server
participant H as Creator Homeserver
Note over V,H: Step 1: Discovery
V->>H: GET /pub/<app_id>/posts/<id>
H-->>V: preview + lock link
Note over V,H: Step 2: Get unlock conditions
V->>H: GET /pub/locks.app/<lock_id>.json
H-->>V: LockPolicy JSON
Note over V,H: Step 3: Resolve Lock Server
V->>H: GET /pub/locks.app/config.json
H-->>V: default Lock Server pointer
Note over V,V: Step 4: Lock-specific process
V->>V: satisfy criteria
Note over V,L: Step 5: Proof bundle submission
V->>L: POST /proof-bundles { submitted_proof_bundle }
alt Under configured submission limit
L-->>V: { "creator": "...", "bundle_id": "...", "status": "pending" }
else Limit exceeded
L-->>V: 429 Retry-After: <seconds> { "error": { "code": "rate_limited", "message": "rate limit exceeded" } }
end
Note over V,L: Step 6: Async polling
loop Until eligible or failed
V->>L: POST /verification-task-lookups { "creator": "...", "bundle_id": "..." }
L-->>V: { "creator": "...", "bundle_id": "...", "status": "in_progress" }
L->>L: Verify proof
L->>L: Use third party if necessary
end
alt Verification succeeds
L->>H: PUT /priv/locks.app/proofs/<bundle_id>.json [header: JWT]
H-->>L: 200 OK
L-->>V: { "creator": "...", "bundle_id": "...", "status": "completed" }
else Verification fails
L-->>V: { "creator": "...", "bundle_id": "...", "status": "failed" }
end
Note over V,L: Step 10: Access content
V->>L: GET opaque bearer credential returned by `POST /access-credentials`
L->>H: GET /priv/locks.app/content/<content_id> [header: JWT]
H-->>L: 200 OK
L-->>V: 200 OK
- Creator revokes the old Lock Server session/grant.
- Creator grants the new Lock Server access to guarded content and stored verified proof bundles.
- Creator updates
/pub/locks.app/config.jsonwith the new Lock Server pointer. - If a lock policy used a per-lock service override, that lock policy also needs to be updated.
- Viewer presents their stored
bundle_idto the new Lock Server. - New Lock Server reads the verified proof bundle from the creator's guarded Locks proof path.
- New Lock Server decides whether to issue a new access credential.
Because verified proof bundles are stored on the creator's homeserver, migration does not require trusting or querying the old Lock Server's private database.
Public path:
pubky<creator_z32>/pub/locks.app/<lock_id>.json
Example:
{
"version": 1,
"creator": "pubky<creator_z32>",
"primary_resource": {
"path": "/priv/locks.app/content/post.json",
"hash": "<blake3 guarded resource hash>",
"content_type": "application/json",
"size": 1234
},
"secondary_resources": {
"/priv/locks.app/content/attachments/image.png": {
"hash": "<blake3 attachment hash>",
"content_type": "image/png",
"size": 4567
}
},
"criteria": [
{
"criterion_id": "crit_1",
"verifier_type": "paykit-payment",
"params": {
"recipient_pubky": "pubky<creator_z32>",
"amount": "50000",
"asset": "BTC"
}
}
],
"lock_logic": {
"type": "all",
"criteria": ["crit_1"]
},
"access_policy": {
"requested_credential_ttl_seconds": 3600
},
"lock_server": {
"override": "pubky<lock_server_z32>"
},
"created_at": "2026-05-07T00:00:00Z",
"updated_at": "2026-05-07T00:00:00Z"
}requested_credential_ttl_seconds controls the requested lifetime of a Lock-Server-issued access credential. It does not necessarily define the lifetime of the underlying entitlement. Entitlement lifetime is lock-type-specific.
For example:
- A paid article may create a durable entitlement.
- A rental may create a time-limited entitlement.
- A subscription may require renewal or re-checking.
- A follower-only lock may require identity and relationship re-checking.
- A password lock may issue only a temporary access credential.
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.
Example:
{
"version": 1,
"bundle_id": "<cryptographically random bearer secret>",
"pubky_lock_resource": "pubky<creator_z32>/pub/locks.app/<lock_id>.json",
"reader_public_key": "pubky<reader_z32>",
"proofs": [
{
"criterion_id": "crit_1",
"verifier_type": "paykit-payment",
"payload": {}
}
]
}The bundle_id must be cryptographically random and treated as a bearer secret. The viewer is responsible for storing it for future reference.
paykit-payment v1 submissions are single-proof only: do not mix payment and non-payment proofs in the same bundle. After rate limiting and current canonical lock/reader preflight, the Lock Server checks the permanent lifecycle identity { creator, bundle_id }. An exact persisted replay returns the existing lifecycle, while changed submitted proof material conflicts; neither replay calls Paykit again. Only a new identity requires Paykit configuration and creates a signed invoice with { bundle_id, lock_resource, reader }.
The worker checks payment through a signed canonical { creator, bundle_id } request to POST /transactions/status. Valid undetected, detected, and confirmed responses are evaluated against amount matching and the configured confirmation threshold. Transport, timeout, HTTP (including 404 or authorization), and response-decoding failures all durably return the task to pending for retry; v1 has no terminal Paykit payment failure. Responses never include invoice data, payment status internals, raw proof material, or an internal task ID.
A verified proof bundle is stored only after successful verification. It functions as an entitlement record.
Guarded path example:
pubky<creator_z32>/priv/locks.app/proofs/<bundle_id>.json
Example:
{
"version": 1,
"bundle_id": "<cryptographically random bearer secret>",
"status": "verified",
"creator": "pubky<creator_z32>",
"pubky_lock_resource": "pubky<creator_z32>/pub/locks.app/<lock_id>.json",
"lock_hash": "<blake3 of canonical content lock>",
"resource_set": {
"primary_path": "/priv/locks.app/content/post.json",
"resource_hashes": [
"<blake3 guarded resource hash>",
"<blake3 attachment hash>"
]
},
"verified_at": "2026-05-07T00:00:00Z",
"verified_by": "pubky<lock_server_z32>",
"criteria_satisfied": ["crit_1"],
"proofs": [
{
"criterion_id": "crit_1",
"verifier_type": "paykit-payment"
}
],
"entitlement": {
"type": "durable",
"expires_at": null
}
}A replacement Lock Server can use this record to decide whether to issue a new access credential. Depending on lock type, it may accept the record as sufficient, re-check external proof, reject it if the policy or content changed, or renew it if the entitlement is still valid.
The final access credential format is not defined in this draft.
The current placeholder is:
opaque bearer credential returned by `POST /access-credentials`
The access credential is currently an opaque bearer secret, not a URL. It must be non-guessable. It should be short-lived unless the lock type explicitly allows durable access. It is issued by the Lock Server after successful verification or after resolving an existing verified proof bundle.
Because viewers may be anonymous, bearer-style access is allowed. This means access credentials may be shareable. That is an accepted trade-off for anonymous-compatible locks. Lock types that require stronger non-transferability may require viewer Pubky identity and bind the access credential to that identity.
The Lock Server can read guarded content that the creator authorized it to read. This is an explicit trust trade-off. Locks preserves existing Pubky homeserver trust assumptions, but it introduces an application-layer trusted proxy.
A future design could combine Locks with encrypted guarded payloads, where the Lock Server releases or unwraps a decryption key instead of seeing plaintext. That is out of scope for this draft.
Since viewers may not have Pubky identities, anonymous-compatible locks use bearer-style secrets such as bundle_id and access credentials. These can be shared by the viewer. TTLs and lock-specific policy should limit damage where needed.
Identity-required locks can require a Pubky viewer identity and signed requests.
bundle_id is sensitive. It should:
- be cryptographically random
- be treated as a bearer secret
- not appear in public lock policy data
- be stored by the viewer if future reference is desired
- be validated by the Lock Server before being used as a filename
- not allow path traversal or caller-controlled arbitrary paths
Including a hash of the guarded content helps identify what exact payload the lock applied to. This is especially useful if guarded content or lock policy is later removed and overwritten.
A verified proof bundle should store both the lock policy hash and guarded content hash so a future Lock Server can distinguish old entitlements from current content.
The current Lock Server HTTP implementation protects POST /proof-bundles with a configurable process-local fixed-window rate limit. The limit is keyed by client network address and content creator, not by Bundle ID, because viewers can generate unlimited Bundle IDs.
Default runtime config:
[rate_limits.verification_submission]
enabled = true
max_requests = 60
window_seconds = 60When the limit is exceeded, the server returns Retry-After with the remaining fixed-window time in seconds:
429 Too Many Requests
Retry-After: <seconds>{
"error": {
"code": "rate_limited",
"message": "rate limit exceeded"
}
}This is an abuse guard only. It does not replace proof-bundle idempotency/conflict checks, entitlement lifetime, credential TTL, or lock-type-specific policy.
The current Lock Server HTTP implementation exposes small operator-facing health/readiness endpoints. These are runtime/process routes, not product/domain use cases.
GET /healthz reports liveness only once the HTTP router is serving:
200 OK{
"status": "ok"
}GET /readyz reports whether the configured runtime dependencies are usable.
Ephemeral runtime currently means in-memory process composition:
200 OK{
"status": "ready",
"runtime_storage": "ephemeral",
"worker_enabled": true
}Persisted runtime currently means Postgres-backed runtime. When the Postgres pool ping succeeds:
200 OK{
"status": "ready",
"runtime_storage": "persisted",
"worker_enabled": true
}When the persisted runtime dependency is unavailable:
503 Service Unavailable{
"status": "not_ready",
"runtime_storage": "persisted",
"worker_enabled": true
}Health/readiness responses are deliberately secret-free. They do not expose database URLs, secret paths, Lock Server identities, worker IDs, task IDs, proof material, access credentials, or rate-limit counters.
-
What is the final access credential format?
- signed URL
- Lock-Server-issued JWT
- cookie/session
- another bearer token format
- viewer-bound token when viewer has Pubky identity
-
What is the final narrow capability model for guarded Locks paths?
- Current implementation may require broader guarded permissions.
- Desired end state is a Locks-specific private namespace.
-
Should
bundle_idbe entirely viewer-generated, entirely server-generated, or jointly derived?- Viewer needs to store it.
- Lock Server must prevent collisions and unsafe path usage.
-
What canonicalization is used for the content lock hash?
- Current implementation uses BLAKE3 over canonical content-lock JSON; future Lock Servers need to preserve that invariant when verifying that an entitlement belongs to a particular policy version.
-
Should verified proof bundles store raw proof details, only verification results, or both?
- Storing raw proof details helps migration.
- It may increase privacy sensitivity.
-
How should each lock type define entitlement lifetime?
- durable
- temporary
- renewable
- re-check on access
- one-shot
-
Should the Lock Server store any failed verification attempts?
- Current proposal says no entitlement is stored unless verification succeeds.
- Current implementation has a process-local fixed-window proof-submission rate limit; durable failed-attempt audit storage remains out of scope.