PKU harness
Cloud

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.

KeyAlgorithmUsed for
Signing keyEd25519Signing token challenges
Box keyX25519Key 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 bits

salt 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, base64url

A 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:

  • aud stops 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 sub and plg, 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.

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 only joinHash = 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 channel meta and 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: maxPeers defaults to 1. The first joiner claims the channel and anyone after that gets 409 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.

A share link contains a decryption key, so the app keeps it out of the AI model's context:

  • The SDK's SecretLink is 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. See scrubShareLinks.
  • Tools never return links. Screens show them to the user with host.shareLink or <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:

ResourceDefault
Store1 MB and 1,000 keys per plugin per device, 64 KB per value
Channels5 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 joins20 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.

On this page