Security
How PKU harness Cloud keeps user data unreadable to the server. Device keypairs, proof-of-work registration, short-lived signed plugin tokens, XChaCha20-Poly1305 envelopes, share-link secrets in the URL fragment, claim-once channels and share-link redaction.
The cloud is designed so that the server only forwards and stores ciphertext it can't read, and doesn't know who the user is. This page describes the mechanisms behind that in order: how a device gets an identity, how it gets tokens, how data is encrypted, how share links protect their secret, and which risks remain.
Device identity
The first time a device uses the cloud, it generates two keypairs. The secret keys are kept in the system keystore. They can be used only on this device, and only after it has been unlocked once since boot.
| Key | Algorithm | Used for |
|---|---|---|
| Signing key | Ed25519 | Signing token challenges |
| Box key | X25519 | Key agreement |
The server knows the device only by these two public keys. It doesn't learn a student ID, a name
or a phone number. Settings → Cloud shows a fingerprint of the signing key and offers two
actions, reset identity and delete cloud data. Deleting cloud data calls
DELETE /v1/devices/me, which the app also calls before a full reset.
Proof of work at registration
Registering a device (POST /v1/devices) needs no account, but the device must first solve a
proof of work. A proof of work is a puzzle that takes some computation to solve and very little
to check. One registration is cheap, but creating identities in bulk becomes expensive.
The alternative would be for the server to verify that requests come from the official app, but that would stop self-built APKs from working, so we don't do it.
The puzzle is defined as follows:
prefix = UTF-8("pku-harness-cloud/v1/pow\n" + salt + "\n" + signPub + "\n" + boxPub + "\n")
hash = SHA-256(prefix ‖ UTF-8(nonce)) # nonce: decimal, no leading zeros, ≤ 15 digits
valid = hash has ≥ difficulty leading zero bitssalt and difficulty come from GET /v1/meta, and the server accepts both its current and its
previous salt. The default difficulty of 16 takes about a second on a phone. Clients refuse any
difficulty above 28, so a hostile provider can't make phones compute forever. Both public keys
are part of the prefix, so one solution registers exactly one identity.
Plugin tokens
Every request a plugin makes carries a bearer token. A token belongs to one device, one plugin and a set of services, and it is valid for 10 minutes. The device gets a token by signing this challenge with its Ed25519 signing key:
pku-harness-cloud/v1/auth
<aud> provider origin, lowercase scheme://host[:port]
<deviceId>
<plugin>
<services> deduplicated, sorted, joined by ","
<ts> Unix ms
<nonce> 16 random bytes, base64urlA token for server functions adds one more line, doc <hash> <link>, which records the plugin
document version the user consented to.
Each part of the challenge guards against a different attack:
audstops a provider from replaying a signature at another provider.- The server accepts only requests where
|now − ts| ≤ 5 min, and rejects a nonce it has already seen in that window. - Store namespaces and channel ownership come from the token's
subandplg, never from the request path, so one plugin can't touch another plugin's data. - Plugins never see tokens. The host mints them and attaches them to requests.
Envelopes
All encrypted data, both in transit and stored on the server, uses one format, which we call an envelope:
{ "v": 1, "n": "<24-byte nonce, base64url>", "c": "<ciphertext ‖ 16-byte tag, base64url>" }An envelope uses XChaCha20-Poly1305 with a randomly generated 24-byte nonce. Encryption can include associated data, such as a channel id or a frame kind, and whoever opens the envelope must supply the same associated data.
This encryption is implemented once, in @pku-harness/cloud-protocol, on the audited
noble libraries (pure JavaScript). The app (on Hermes), the
reference server (on Bun) and viewer pages (in the WeChat in-app browser, which has no WebCrypto)
all run that one implementation.
Share links and channels
A share link looks like this:
https://<provider>/s/<channelId>#k=<base64url 32 bytes>The secret k sits in the URL fragment, the part after #. Browsers never send the fragment to
the server, so the server never gets k. The device and the viewer derive two keys from k:
authKey = HKDF-SHA256(k, "pku-harness-cloud/v1/join")is used to join the channel. The server stores onlyjoinHash = base64url(sha256(authKey)), so it can check joins without being able to join.encKey = HKDF-SHA256(k, "pku-harness-cloud/v1/enc")never leaves the devices. It encrypts the channelmetaand is an input to each peer's key.
To join, the viewer sends authKey and a freshly generated X25519 public key. From then on,
frames between the owner and that peer are encrypted with this key:
HKDF-SHA256(ikm = X25519(own, other) ‖ encKey, salt = chanPub ‖ peerPub, info = "pku-harness-cloud/v1/peer")Channels have two more protections:
- Claiming:
maxPeersdefaults to 1. The first joiner claims the channel and anyone after that gets409 conflict, so a forwarded link is useless. The owner can kick a peer to free the seat. - Checks the server can't verify, such as a masked-name hint or a PIN, run on the owner's
device. The server only rate-limits joins (20 per IP per minute) and locks a channel after 5
wrong
authKeys.
Keeping links away from the AI model
A share link contains a decryption key, so the app keeps it out of the AI model's context:
- The SDK's
SecretLinkis an opaque object.JSON.stringify(link)gives only"[secret link]". - The host removes strings that look like share links (
/s/<id>#k=…,pkuharness://share/<id>#k=…) from tool output and logs. SeescrubShareLinks. - Tools never return links. Screens show them to the user with
host.shareLinkor<QRCode link>.
Limits
Rate limits use token buckets, one set per (deviceId, plugin, service) and one per IP, and the
current state is reported in RateLimit-* headers. When the server answers 429, the host throws
CloudError('rate_limited', retryAfterS). The defaults are:
| Resource | Default |
|---|---|
| Store | 1 MB and 1,000 keys per plugin per device, 64 KB per value |
| Channels | 5 open per device, lifetime 60 s to 24 h (default 15 min), at most 5 peers, 4 KB frames, 60 frames/min, 64 mailbox slots |
| Channel joins | 20 per IP per minute |
| Push (planned) | 100 pending schedules |
| Functions (off on the official server) | 60 calls per minute |
Residual risks
These mechanisms don't remove every risk. Three kinds of risk remain.
Metadata. A provider sees request times, data sizes and IP addresses. A plugin's disclosure lines have to say so.
Viewer pages run JavaScript supplied by the provider. A malicious operator could serve a page
that leaks k, and so see what passes through that one channel during the share, though not
campus credentials. We reduce this risk in three ways. Viewer pages are static and built
reproducibly from the plugins repository. They are served with a strict Content Security Policy
(CSP), connect-src 'self'. Viewers who have the app decrypt inside the app through a deep link
and never run the provider's JavaScript.
Server functions see plaintext by design. They must be declared in PLUGIN.md with a disclosure
and approved by the user before use, and the server runs only the document version the user
consented to. They are off on the official server.
Plugin guide
Use PKU harness Cloud from a bundled plugin. Declare the cloud manifest and disclosure, handle consent and availability, use the store and relay channels, and show share links safely.
Providers and self-hosting
How to pick the official instance, a self-hosted reference server or any conforming URL, what a provider must implement, and how to run and configure the reference server.