-
Notifications
You must be signed in to change notification settings - Fork 0
Passway architecture
Status:
- 💡 Seeking feedback (please leave it here)
- 💻️ In development
Passway provides anonymous, secure user authentication and end-to-end encrypted cloud storage. It is meant to be a hosted web service that apps can integrate with. The Direct Users of Passway are app developers who want to build anonymous authentication and end-to-end encrypted cloud-based data storage into their projects. The users of those apps are Passway's Downstream Users.
- All code is open source
- License: FSL (MIT)
- All data transfer is end-to-end encrypted
- Unencrypted data is never transmitted from clients
- Exception: Functionally necessary, non-sensitive metadata will be transmitted from client to server unencrypted
- Unencrypted data is never transmitted from clients
- Authentication is done via WebAuthn
- Stack can be self-hosted
- Data can be securely shared between users
- Can be run on a laptop for local development
- Can be deployed in a data center and horizontally scaled out
Registration and authentication will be based on an unorthodox use of WebAuthn. WebAuthn is meant for authenticating with server-based web services via passkeys (implemented with public/private key cryptography). Though effective, this does not meet Passway's needs because the passkey's private key is not accessible to application code to encrypt data with. To work around this, an ECDSA signature keys and an AES encryption key will be generated on the client, collectively wrapped with AES, and then stored on the server for later retrieval and decryption by a client.
- User initiates registration ceremony and provides their desired user name
- User name and a client-side generated random ID string are provided to
navigator.credentials.create()
aspublicKey
. The user will be prompted to authenticate (typically via a biometric device) and the passkey will then be created. - The user will then be prompted to "log in" for the first time by re-authenticating (typically via a biometric device). This will call
navigator.credentials.get()
and result in aPublicKeyCredential
that contains aAuthenticatorAssertionResponse
.-
PublicKeyCredential.id
(id
for short) is treated as a user ID to identify the client to the server -
AuthenticatorAssertionResponse.userHandle
(userHandle
for short) is treated as a secret key. It must never be sent to the server. - Note:
id
anduserHandle
are only available in thenavigator.credentials.get()
response and notnavigator.credentials.create()
, thus necessitating the user to re-authenticate
-
- The client generates an ECDSA signature key pair and an AES encryption key. These keys are then wrapped with
userHandle
as the AES secret key, and client-generated salt and initialization vector - The client sends a
PUT /api/v1/user
request to the server with theid
, the wrapped keys, and unencrypted public signature key in the payload- The server will store the public key, salt, initialization vector, and wrapped keys
Note
Why userHandle
as the encryption key: Though counterintuitive semantically, application code has influence over the size of userHandle
data but not the size of id
. Bigger is better when it comes to encryption keys, so that is what's used to secure data before sending it to the server.
id
and userHandle
are the only stable strings that are stored with the passkey, so a more semantically sensible property of the passkey cannot be used for identification and decryption purposes.
It should be safe to depend on these properties as Passway uses them, as they are a part of W3C specifications:
- User initiates an authentication ceremony and provides their user name
- User name is provided to
navigator.credentials.get()
, prompting the user to provide biometric identification. If successful, this will result in aPublicKeyCredential
that contains aAuthenticatorAssertionResponse
.-
PublicKeyCredential.id
(id
for short) is treated as a user ID to identify the client to the server -
AuthenticatorAssertionResponse.userHandle
(userHandle
for short) is treated as an encryption/decryption key. It must never be sent to the server.
-
- The client sends a
GET /api/v1/user
request with the userid
as thex-passway-id
header. If a user record is found, its metadata (including wrapped keys, encryption salt and initialization vector) is returned to the user in a 200 response. - The client unwraps the keys with the passkey
userHandle
, salt, and initialization vector to reveal the private signature key - The client sends a
GET /api/v1/session
request with the userid
as thex-passway-id
header and a private key-signed version of a shared message between the client and server as thex-passway-signature
header - The server looks up the user data associated with
id
. If found, the server validates the signed message with the public key, creates a session cookie, and returns a200
response
- Must handle user authentication
- Must never expose user's private keys
- Must manage user data encryption key (their WebAuthn public key)
- It is up to the user to manage and safeguard this key
- WebAuthn should make this relatively seamless and can largely automate backups and redundancy via service providers like Bitwarden, Apple, and Google
- It is up to the user to manage and safeguard this key
- Must be easily integrated into consuming apps
- Must support arbitrarily large data transfers
- Must support many concurrent users
- TypeScript-based SDK that gets installed into integrating client apps
- Provides an API for interacting with a Passway server
- Leverages tus for streamed content upload
- Unencrypted user data is never transmitted from the client
Note
Why tus: tus appears to provide a clean abstraction for streaming data from a client to server. Data must be streamed to avoid loading large content data objects in client memory and keep resource usage low.
- Encrypts each data chunk with user's public key before sending it to the server
- For large data uploads, chunks are stored to IndexedDB prior to uploading when it is available (this avoids loading the entire content in memory)
- tus is used to transfer data to the server
- Decrypts each data chunk with the user's encryption key as they are received from the server
- Chunks are cached to IndexedDB when it is available (this avoids storing the entire content in memory)
- Must support arbitrarily large data transfers
- Must support low latency data transfer
- Must support extremely large data storage (many terabytes)
- Node-based application for passkey registration and authentication
- Set up behind NGINX + modsecurity to guard against DDoS attacks
- Stores user data (such as passkey ID) in the data management component (outlined below)
Note
Why Fastify: The authentication API is pretty unexotic, so a conventional web app framework should suffice. Fastify has particularly good support for Swagger, which is a helpful addition to an API such as this.
Note
Why NGINX: NGINX is a tried-and-true solution for managing high traffic load to web servers. The request load management needs of Passway are not particularly unique.
Note
Why Valkey: Valkey is the open source successor to Redis. Redis would also work well, but its license terms are not a good fit for Passway.
- Uses tus-node-server to receive content upload streams and pipe them to MinIO data storage
- Authenticates requests by comparing
Authorization
header to what's available in the Authentication system's session store
- User account information and content metadata is stored in PostGres
- This PostGres DB also contains tables that associate user accounts to user content objects
- User content is stored in MinIO by default
- The data storage back end should be treated as an implementation detail. There should be a common interface that allows users to swap MinIO for another data store such as S3 or Backblaze B2
- Stores both metadata on individual users (such as their username and public key) and their application data
- Scaling is done horizontally via MinIO Server Pools
- When quota is exceeded, write operations fail with a descriptive error message
- Data write operations are atomic upserts
- When a data stream is received, it is stored in a temporary MinIO object and then moved to the object specified by the associated
content-key
once all chunks are received- This is done to ensure that data is not corrupted if streams are interrupted
- When a data stream is received, it is stored in a temporary MinIO object and then moved to the object specified by the associated
- Defines a quota of available content storage for a given user ID (configurable, but a good default could be 10 GB)
- Content associated with a stale (unused) Passkey can be set to be evicted at a configurable interval, along with the passkey
- This is intended to be an abuse mitigation (e.g. a malicious user storing useless data to consume DB space) and resource management mechanism
Note
Why PostGres: User account CRUD operations are likely to be most straightforward with a relational database, and PostGres seems to be as good of an option as any.
Note
Why MinIO: The user content that Passway stores will be encrypted before it reaches the server. This reduces the server to being little more than a dumb data store for which object storage seems like a good fit. MinIO also supports streamed uploads, which is necessary for managing memory usage. MinIO is also S3-compatible, so it should also work nicely with tus's S3/Node adapter.
The registration experience depends on the user agent. Passway defers this concern to navigator.credentials.create
.
For example, here's the registration flow on Firefox with Bitwarden (a password manager) integrated:
Here's the registration flow for Chromium (with no third-party password manager integration):
Route:
PUT /api//v1/user
Headers:
x-passway-id: "<user ID>"
Payload:
{
"iv": "<encryption key initialization vector>",
"salt": "<encryption key salt>",
"keys": "<wrapped encryption keys>",
"publicKey": "<public signature key>",
}
Route:
GET /api/v1/user
Headers:
x-passway-id: "<user ID>"
Response:
{
"iv": "<encryption key initialization vector>",
"salt": "<encryption key salt>",
"keys": "<wrapped encryption keys>",
}
Route:
GET /api/v1/session
Headers:
x-passway-id: "<user ID>"
x-passway-signature: "<signed version of shared string>"
Response:
A session cookie called passwaySessionId
.
Route:
GET /api/v1/content/:contentKey
Headers:
passwaySessionId: "<session token>"
X-Passway-User-Id: "<user ID>"
If the request is valid, the server would then respond with an HTTP 200
response along with the encrypted content as binary data. If the request is invalid, the server would respond with the appropriate HTTP status code and error message.
This is done via tus-js-client.
Route:
POST /api/v1/content/:contentKey
Headers:
passwaySessionId: "<session token>"
x-passway-id: "<user ID>"
The request payload is the encrypted content as binary data. The specific details and behavior of this API may change as a result of tus implementation details and requirements.
Route:
GET /api/v1/user
Headers:
passwaySessionId: "<session token>"
x-passway-id: "<user ID>"
200
response if user exists:
{
"user-id": "<some user ID>",
"error": null,
"publicKey": "<public key for user-id>"
}
Or an error with a message and appropriate HTTP status.
Users should be able to securely share data with each other through Passway. This can be deferred to a post-MVP iteration, but the initial design should account for it.
Assuming User A wants to share data with User B:
- User A produces a one-time use message containing a random key and content metadata (such as its ID), and encrypts their data with it
- User A sends the encrypted message to the server to be stored (like any other data)
- User A retrieves the public key of User B from the server
- User A encrypts a message containing the one-time message key and nonsensitive metadata about the shared data with User B's public key
- User A sends the encrypted metadata message to the server to an "inbox" on the server for user B
- User B retrieves the encrypted metadata message from their inbox on the server
- User B decrypts the metadata message with their private key which contains content ID and decryption password
- User B retrieves the encrypted content from the server
- User B decrypts the downloaded content
- User loses access to passkey
- User's access to their data is lost
- Data may be evicted after being stale for a configurable period of time