Inspiration

Our primary inspiration for this project stems from a day when three apps that one of our members was using all separately required him to prove his age. Since he wasn't comfortable uploading his ID or face to a server where, although they take precautions, data breaches still routinely occur, he had to accept that he would now be using a reduced feature set for these apps. This is an issue we are passionate about as privacy focused consumers because data breaches will only become more devastating and frequent as more of our data needlessly lingers online

To rent an apartment, you email a stranger your driver's license, three pay stubs and three months of bank statements. Those files sit in inboxes and shared drives for years, and they show far more than anyone asked about: every purchase, your balance, your address, your employer. The landlord only needed one answer: is this person's income at least 3× the rent?

We wanted to build the version where the landlord gets exactly that one answer, and can trust it, without ever seeing the documents.

We named it Janus, after the Roman god with two faces. Janus shows each landlord a different face, a separate ID for every landlord, so even landlords who compare notes can't tell they're dealing with the same person.

What it does

Janus lets a tenant prove one fact without sharing the evidence behind it.

  1. A landlord creates a request on the portal ("income ≥ 3× \$2,400 rent?" or "are you 18 or older?"), and the tenant gets it by text message.
  2. The tenant opens the link in the Janus app, which runs on their own machine, and sees exactly what will be shared: one yes or no.
  3. The app reads the evidence locally: bank deposits (Capital One Nessie or Plaid sandbox), uploaded pay stubs, bank statements, W-2s or 1040s, or a photo of an ID for the age check.
  4. It computes the answer on the device and sends back a signed claim with no dollar amounts, no documents and no date of birth.
  5. The portal verifies the signature and shows ✓ or ✗, plus how strongly the answer is backed.

For bank income, the yes or no isn't just signed: a zero-knowledge proof shows the answer follows from deposits the bank itself signed. The tenant can't edit the app to always say yes, and the landlord never learns the income.

How we built it

  • Monorepo in TypeScript: a Next.js landlord portal, a Next.js tenant app, a stand-in bank signer, and shared claim code.
  • Signed claims use Ed25519 through jose. We never wrote our own crypto. Each claim is bound to the request with a one-time nonce, so reusing a claim fails, and it expires after 7 days.
  • The income rule counts only payers who deposit at least 3 times, and compares the median month to the rent: $$\operatorname{median}(m_1, m_2, m_3) \;\ge\; k \cdot r$$ The median ignores a one-time transfer made the day before applying.
  • Zero-knowledge proof: the rule is a Noir circuit proved with Barretenberg. The bank signs a salted commitment to the deposit list, and the portal checks both the bank's signature and the proof.
  • Document reading on the device: unpdf for PDF text, tesseract.js for photos, zxing-wasm for the PDF417 barcode on the back of a driver's license, and an MRZ parser with check digits for passports. Each document gets arithmetic checks (for a statement, opening balance + deposits − withdrawals = closing), tamper signals, PDF signature verification with PKI.js, and a cross-check against the bank's own deposits.
  • Texting goes through Photon (Spectrum iMessage). A tenant can reply YES to approve.
  • A tamper check: the tenant app hashes its own code and compares it to a release manifest signed by a separate key. If it doesn't match, it refuses to sign.
  • CI runs unit tests, both builds, an end-to-end smoke test, the collusion test and a document accuracy test on every push.

Challenges we ran into

Our first version leaked identity through time. We built a collusion test: two landlords pool every claim they received and try to work out which ones came from the same tenant. The pairwise IDs differed per landlord, as designed, yet the test matched tenants with 100% accuracy, because the exact signing timestamps lined up. We rounded timestamps down to the UTC day, and matching accuracy dropped to 5.04% in our synthetic test with 20 tenants, close to the 5% random-guessing baseline.

A fake statement taught us when not to "fix" the scanner. Our test bank statement was rejected as unreadable. Part of that was our fault: the parser didn't understand dates like "Oct 03, 2026" or credits written as "+\$2,900.00". The rest was the scanner doing its job. The statement was dated two weeks in the future, and its listed withdrawals (\$461.69) didn't add up to the stated total (\$4,150). We fixed the parser and left those checks alone.

Real-world plumbing. Texting failed with a 403 until we found we were using the wrong kind of project ID. Replying YES failed because the server on Render couldn't reach the tenant app on a laptop, which we solved with a tunnel. Then it failed again because a redeploy wiped the in-memory link between a phone number and its request.

Coordinating three people and three AI tools (Claude, Codex, Cursor) on one repo in 24 hours. A shared AGENTS.md gave every assistant the same rules: no dollar amounts in the claim, no hand-rolled crypto, and nobody changes the claim format without its owner.

What we learned

  • Privacy depends as much on metadata as on content. A timestamp, a document type or an exact age can link a person across services. Every field in our claim now has to justify itself.
  • Being honest about trust boundaries matters more than overclaiming. A software tamper check can be patched, so production would use phone hardware attestation. A photo of an ID can be forged, so every age answer is labeled document unverified; mobile driver's licenses signed by the issuing state are the real fix.
  • On a synthetic, labeled corpus of 390 documents, the scanner read every genuine document correctly and rejected none of the 180 genuine ones we scored. It caught every forgery whose numbers no longer added up, and the bank cross-check caught the careful ones that did.

What's next

State-signed mobile IDs for the age check, banks that sign their own data, a zero-knowledge proof for age, and the same "prove one fact" flow for platforms and hotels that need an age check but shouldn't hold copies of IDs.

Built With

Share this project:

Updates

Submission history