Skip to content

feat(share): optional password protection for share links (v0.17.0) - #315

Merged
MerciHanrim merged 8 commits into
mainfrom
feat/share-link-password
Oct 4, 2026
Merged

MerciHanrim merged 8 commits into
mainfrom
feat/share-link-password

Conversation

@MerciHanrim

@MerciHanrim MerciHanrim commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Part of #300. Version 0.17.0; the release date is set on the day of the merge (the note currently says 2026-10-04). Do not merge yet: the merge is the production deploy, and the password-manager observation below runs on this pull request's preview first.

What changes for a person. The share dialog has one more choice, Protect with a password, unticked by default; a plain link is created and opened exactly as before. Ticked, the dialog asks for a password twice (12 to 128 characters), says to send it some other way than the link, that Loop Studio cannot recover or reset it, that the link is only as strong as its password, and that Loop Studio does not store or transmit the password or the key. The result is a #p1= link on the same public address. Opening one asks for the password before anything from the shared diagram is drawn; a wrong password leaves the current diagram untouched and can be retried without limit. Desktop and the phone layout use the same dialog and the same code.

The format, loop-share-protected/1 (docs/specs/SEMANTICS-P.md). The payload is base64url of a 16-byte salt, a 12-byte IV, the ciphertext and the 16-byte tag, with no header byte. The plaintext is the same zlib-deflated Graph JSON a plain link carries. The key is PBKDF2-HMAC-SHA-256 over the NFC-normalised password at 600,000 iterations into AES-256-GCM, never extractable, with encrypt usage when sealing and decrypt when opening. The additional authenticated data is the format identifier, so a link re-labelled as another version does not open. Nothing in a link selects a parameter. Web Crypto only; no cryptographic code is written or bundled.

Opening, in fixed order. The storage gate of #297, the structure check (60 to 8,192 characters of strict base64url) before any password is asked for, the sealed bytes copied to memory and the fragment removed, the prompt, key derivation, decryption, the bounded inflate, parse and validation, the replace confirmation, one load, and only then the first-run Welcome card. A broken link shows a notice and loses its fragment. A link from a newer version and a page without Web Crypto show a notice and keep the fragment. A wrong password, a failed authentication and altered ciphertext are one message and one console line. Content that authenticates and is not a diagram is a different notice with no retry.

What it does not claim. It cannot hide the length of the ciphertext or that a link was used; the ciphertext, salt and IV stay in browser history and chat logs, so guessing offline has no limit; it is not sign-in or access control. docs/specs/SEMANTICS-U-ERRATA.md records what a plain link really carries (saved frames and imported spreadsheet records) without rewriting the frozen loop-share/1 document.

Commits.

commit what alone
578fcd9 the sealed transport, unit tests against an independent node:crypto implementation, check:share-crypto fails check:change-declaration
ab8a227 the fragment grammar and the opening flow fails check:change-declaration
13eb6f0 the two dialogs, English and Korean does not type-check; fails check:i18n, check:form-direction, check:icu-arguments, check:change-declaration
f3b9e1c sixteen more languages, version, release note, guards green
a97853b end-to-end tests, the specification and the errata green
2a09f21 four corrections from review green
c4ea6a7 a test-only fix of an unrelated playback test green
5ee4272 a Copy button on the phone's share result sheet green

Verification, in the order it happened.

  • First full local run, at a97853b: everything passed. Listing 1,862 tests, run 1,862 (1,855 passed, 7 skipped, none failed, none missing, duplicated or extra), production bundle 15 of 15, PWA 18 of 18, unit 2,971 of 2,971, type-check, lint at its baseline of 39 warnings and no error, every check, three builds and the precache closure.
  • Corrections in 2a09f21: the recovery sentence no longer reads as a claim about the whole service; the release note no longer says a damaged link in general gives the wrong-password message; the pt-PT bound on how much may differ from pt-BR is a quarter again, with the password strings held separately by an exact list and by banning senha; the guard that keeps an Enter inside an input-method composition from submitting now tracks the composition itself and has two tests.
  • Second full local run, at 2a09f21: one failure. Listing 1,864, run 1,864 (1,856 passed, 7 skipped, 1 failed); production bundle 15 of 15, PWA 18 of 18, unit 2,971 of 2,971, everything else passed.
  • The one failure was playback-choreography.spec.ts, "§PB4.4 — at L0 the travelling dot is elided". It is a race in that test, present before this branch: the test polled every 30 ms for a state that lasts about one animation frame. Thirty runs of the test alone failed 12 times on a copy of main (3796589) and 10 times on this branch, with the same message.
  • c4ea6a7 changes that test only: a store subscription installed before Play catches the first settle in the notification that creates it and pauses there. No product code, no baseline, no speed, bound or retry change. Thirty runs each afterwards: 30 of 30 on a copy of main carrying only this file, 30 of 30 on this branch; the whole spec passes.
  • At c4ea6a7 the protected-link specs, the plain share specs, the confirm-dialog spec, the phone test, the share unit tests, type-check, lint and the related checks were run again and passed. CI at c4ea6a7 was green on its first attempt: 1,864 tests, the five shards an exact partition of the listing, none failed, none retried, the same 7 skipped.
  • 5ee4272 came out of checking that preview on an iPhone: the phone's result sheet copied the link once and offered no way to copy it again, which matters more for a link that takes a password typed twice to make again. The sheet now has a Copy / Copy again button that copies the same link and nothing else; desktop and phone share one copy function and the existing strings. At 5ee4272: the whole phone spec (55 tests, five of them new), the plain and protected share specs, the confirm-dialog and toolbar specs (62), unit 2,971 of 2,971, type-check, lint and every check passed locally.
  • A third full local run was not made; the full five shards at the final commit are this pull request's CI.

Measured before implementation.

  • The Cloudflare Web Analytics beacon does not transmit the URL fragment: six captured reports, at load, after the app started and when the page was hidden, each with location equal to the origin and path only, for a #g1= and a #p1= marker; the beacon's source clears hash and search. Nothing was sent during the measurement. The beacon script was fetched twice in all, because a first run of the probe stopped on a wrong selector before writing a result.
  • Key derivation at 600,000 iterations: 85 ms on an iPhone 15 Pro (149 to 151 ms in Low Power Mode), 101 ms on a Nothing Phone (1) (119 ms in battery saver), no frame gap above 50 ms.
  • Every bundled template in every shipped language fits the cap as a protected link; the largest is 7,059 characters of 8,192, and 7,983 with the pure JavaScript compressor.
  • The shipped v0.16.0 build leaves #p1=… in the address bar untouched, so a build that predates this never consumes a protected link.

Guards. check:share-crypto keeps key and cipher calls in one module with the iteration count and the identifier written once; seventeen mutations of a scratch copy each fail it. Catalog 944 to 977 keys. One existing test changed with the product: the share dialog has a third control, so the focus-trap test walks Cancel, Create link, the box, Cancel.

Not verified.

  • What a browser or a password manager offers to save or fill, beyond desktop Chrome. On desktop Chrome 154 with a throwaway profile, at c4ea6a7: no strong-password suggestion; after a link is created the address bar shows Chrome's save-password icon and no popup; nothing is saved without accepting; creating and opening work; the field is empty when the link is opened again. iPhone Safari and Android Chrome are still to be observed on this preview, never with a real password.
  • A real input method: the two composition tests dispatch compositionstart and compositionend by hand around a real Enter key press. They pin the guard and are not a test of an IME.
  • Web Crypto on file:// outside Chromium, a real screen reader, and key derivation on a low-end phone.
  • The sixteen translations other than English and Korean were written in this change and have had no native review.

…t an independent implementation

Part of #300. The first of five staged commits; nothing is reachable from the UI yet.

`src/model/shareProtected.ts` is loop-share-protected/1: a `p1` payload is base64url of salt (16), IV (12), ciphertext and tag (16), with no header byte. The plaintext is the zlib-deflated Graph JSON, the bytes a plain `g1` link encodes. The key is PBKDF2-HMAC-SHA-256 over the NFC-normalised password at 600,000 iterations into AES-256-GCM, never extractable, with `encrypt` usage when sealing and `decrypt` when opening. The additional authenticated data is the UTF-8 format identifier, so a link re-labelled as another version does not open. Web Crypto only.

The structure check (60 to 8,192 characters, strict base64url) runs before any key is derived. Every failure of the derivation or the decryption leaves the module as one typed error with one reason: the browser reports data shorter than the tag with a different message, and that message must never reach a screen or a log. Content that authenticates and is not a bounded zlib stream is a separate reason. The module has no console call and keeps no password.

The unit tests carry a second implementation written with node:crypto and node:zlib and their own base64url. Each side opens what the other sealed, a fixed known-answer vector opens, and mutations of the independent side (another identifier as AAD, no AAD, 599,999 iterations) do not. Product code therefore has no way to be handed a salt or an IV. The two Node modules are declared by hand in `src/test/nodeReference.d.ts` so the app's type program does not take on Node's globals.

`npm run check:share-crypto` keeps key handling in that one module: the key and cipher operations, `subtle` and `getRandomValues` are named only there (plus the existing digest and id generation), nothing imports a `node:` module, the iteration count and the identifier are written once, `extractable` is the literal false, and package.json names no cryptography library. Seventeen mutations of a scratch copy each fail it.

Alone, this commit fails `check:change-declaration`: the declaration and the 0.17.0 release note land with the last commits of the pull request.
… link

Part of #300. The second of five staged commits. The flow has no dialog yet, so at this commit a `#p1=` link waits for an answer nothing on screen can give; the dialogs follow in the next commit and the pull request ships as one.

`classifyFragment` gains the protected grammar beside the plain one: `p1=<payload>` is a protected link, `p<n>=` with another number is one from a newer version, and `p1` without `=` is a broken one. The plain `g1` rules and their order are unchanged. A build that predates this classifies all three as foreign and leaves them in the address bar, measured on the shipped v0.16.0 build.

`src/store/protectedLink.ts` is the flow, in the fixed order: the structure check, the sealed bytes copied to memory and the fragment removed, the password prompt, key derivation, decryption, the bounded inflate, parse and validation, the replace confirmation, one load. A broken link shows a notice and loses its fragment without a password being asked for. A link from a newer version and a page without Web Crypto show a notice and keep their fragment, so the same address opens after an update or elsewhere. A wrong password leaves the prompt open with no limit; it and a damaged link write the same single console line. Content that authenticates and is not a diagram ends in a different notice with no retry. One derivation runs at a time, and a cancel during it drops its result. The password is an argument and is kept nowhere; the store holds a phase, a busy flag and a failure count.

The promise `consumeShareLink` returns resolves only when the whole flow has ended, so the first-run Welcome card waits behind the prompt instead of covering it.

The replace confirmation and the one load move to `src/store/shareApply.ts`, shared by both kinds of link; the plain path calls the same code in the same order. The boot module tells the storage gate about a waiting protected link as it does for a plain one.
…n English and Korean

Part of #300. The third of five staged commits. Alone it does not type-check and fails `check:i18n`: the 29 new strings exist in English and Korean only, and the other sixteen languages follow in the next commit.

Creating. `ShareCreateDialog` replaces the plain confirm in the share flow, on desktop and on the phone alike. It is the same disclosure with one more choice, `Protect with a password`, unticked by default; the plain link behaves exactly as before. Ticked, it shows two password fields (`autocomplete="new-password"`), one show or hide control, the 12 to 128 rule, and four sentences: send the password another way, a lost password cannot be recovered, the link is only as strong as its password, and Loop Studio does not store or transmit the password or the key while a browser or a password manager may offer to save it. Without Web Crypto the choice is disabled and says why. While the key is derived the dialog stays open with a status line and everything disabled, and a second submit is ignored. Over the size cap, a missing public address and a failed rule are shown inside the dialog. The password stays in the two input elements, is read once on submit, and goes when the dialog closes; the link is what gets copied, never the password. The result panel says to send the password separately.

Opening. `ProtectedLinkDialog` is mounted once at the root and draws the flow of the previous commit: the prompt with one password field (`autocomplete="current-password"`), a status line while the link opens, and a sentence saying whether the opened diagram is kept in this browser (personal session) or not (temporary session). After a wrong answer the field keeps what was typed, selected. A press on the backdrop does not dismiss the prompt. An Enter that only commits an input-method composition does not submit. The four notices (damaged, newer version, no Web Crypto, content that is not a diagram) are alert dialogs with one Close.

`prepareProtectedShareLink` in `src/ui/shareAction.ts` is the one shared action behind both entry points: seal, the same cap as a plain link, the `#p1=` address on the fixed public base.

Seen working on the dev server in throwaway profiles, desktop and a phone-sized viewport: a link is created and opened, a wrong password leaves the document untouched, the first-run Welcome card waits behind the prompt and appears after it.
…guards that hold them

Part of #300. The fourth of five staged commits; with it the branch type-checks again and every check passes.

The 29 dialog strings are now in all eighteen languages, and four release-note lines (`whatsNew.v0170.*`) say what changed: a share link can be protected with a password, a protected link asks for its password before anything is shown and gives one message for a wrong password and a damaged link, a lost password cannot be recovered and Loop Studio neither stores nor transmits it, and the plain link is still the default. The sixteen translations other than English and Korean were written here and have had no native review.

Version 0.17.0, release note `release:0.17.0`, change declaration `.changes/share-link-password.json`. The date is the day of the merge and is set again right before it.

Guards re-pinned for 33 new keys (944 to 977, runtime surfaces 1,166 to 1,199): `HTTPS` joins the declared Latin and kept-English vocabularies (es-419, it, nl, th, vi) and `password` the Italian one; eight keys join the es-ES audit, where Spain says `usted` and `gestor de contraseñas`; seven words join the Russian list of spellings with the letter yo; the Thai and Vietnamese lists gain the six strings that name Loop Studio or HTTPS. The pt-PT delta goes from 227 to 255, and its bound on how much of the catalog may differ from pt-BR moves from a quarter to 30 percent: the word for a password itself differs (`palavra-passe`, `senha`), so 28 of the 33 strings differ for one reason. The exact count stays pinned.

Two checks learn about the new dialogs. `check:form-direction` now judges an input whose type is a conditional between two literals (the show or hide control) by both branches, and the three password fields declare `dir="auto"`. The ICU argument manifest declares the two size arguments the create dialog passes to `share.tooLarge`.
…ts errata

Part of #300. The last of five staged commits.

`e2e/share-protected.spec.ts` drives the feature through the real UI: the plain link is still the default with focus on Cancel; the rule and a mismatch are said inside the dialog; a protected link is `#p1=` on the public base with the link (not the password) copied, the address bar untouched and the password in no storage key, store or address; over the size cap is an inline message with no alert; without Web Crypto the choice is disabled and the plain link still works. Opening: nothing of the shared diagram is in the document or the accessibility tree before the password; a wrong password keeps the document and its revision, and the typed text stays selected; the right one loads once. A wrong password and a damaged link show the same text and write the same console line. A broken link shows a notice without a password field and loses its fragment; an unknown version keeps it. A modified session is asked before it is replaced. Escape cancels, and a reload does not bring the prompt back. Two fast submits derive one key. A link sealed in the test with node:crypto opens in the browser, and sealed content that is not a diagram is refused without a retry. The first-run Welcome card waits behind the prompt. Behind the storage gate, the gate names the waiting link and keeps its fragment, and a temporary session opens it and stores no document.

One test each where that spec does not run: the phone viewport (the same dialog from the More sheet, 16px fields, the prompt inside the screen), the installed PWA offline, the portable file (a link made on file:// opens on the hosted build), and the production bundle.

Two existing tests change with the product. The share dialog has a third control, so the focus-trap test walks Cancel, Create link, the box, Cancel. The release-note list gains 0.17.0.

Not covered by a test: the guard that keeps an Enter which only commits an input-method composition from submitting; a dispatched key event cannot stand in for a real composition.

`docs/specs/SEMANTICS-P.md` is loop-share-protected/1: the link shape, the fixed parameters, the password rule, the fragment grammar, creating, the opening order, the error table, what it does not claim, the constants and a known-answer vector. `docs/specs/SEMANTICS-U-ERRATA.md` records what a plain link really carries (saved frames and imported spreadsheet records, beyond what the frozen text names) without rewriting the frozen document.
…can hold

Part of #300. Four corrections from the review of the first full run, which passed at the previous commit and is kept as a diagnostic.

The recovery sentence. "There is no server and no reset" could be read as a statement about the whole service. The note now says `Loop Studio cannot recover or reset the password.`, in all eighteen languages.

The release note. "A wrong password and a damaged link give the same message" claimed too much: a link whose structure is broken is not asked a password for and gets a different notice. The line now says `A wrong password and encrypted link data that was altered after creation give the same message.`, in all eighteen languages.

The pt-PT guard. The bound on how much of the catalog may differ from pt-BR is a quarter again, for everything outside this issue's password strings. Those strings are held separately: `senha` joins the banned Brazilian forms, which pins `palavra-passe` in both directions (none left in pt-PT, every pt-BR string that has it changed), and the 28 of the 33 keys that differ are listed exactly.

The composition guard. The inline check read the key event's own `isComposing`, which no test could set. `useCompositionGuard` tracks `compositionstart` and `compositionend` itself, and still honours `isComposing` and key code 229. Two tests dispatch those two events by hand around a real Enter key press, in the creating dialog and in the opening prompt: the Enter inside does not submit, the one after does. With the tracked state removed from the guard both tests fail. They pin the guard; they are not a test of a real input method.

Guards that follow the two strings: `изменённые` replaces `повреждённая` in the Russian list of spellings with the letter yo, and the recovery sentence joins the Thai and Vietnamese lists of strings that name Loop Studio.
Part of #300 only in that its last local run exposed it; nothing here concerns share links, and no product code changes.

`playback-choreography.spec.ts`, "§PB4.4 — at L0 the travelling dot is elided", was the one failure of the full local run at the previous commit. It asserts that exactly one step is committed. Under Play the store starts the next transition on the very next animation frame, so the state "one step committed, no transition in flight" lasts about one frame. The test polled every 30 ms and left its loop only when a sample landed inside that frame; when none did, it left one beat later with two steps committed.

That is a race in the test, not a flake of the product. Measured on this machine, thirty runs of the test alone: 12 failed on a copy of `main` (3796589), 10 failed on this branch, with the same message.

The test now installs a subscription on the simulation store before Play. The subscription sees `stepIndex === step0 + 1` with no transition in the same synchronous notification that creates that state, and pauses there, so the second transition never begins. The poll stays for what it was always good at: no moving dot at L0, the three phases in order, the L0 pulse during travel. The speed, the loop bound and the retry count are unchanged, and the test additionally asserts that the run is paused with no transition in flight.

After the change, thirty runs each: 30 of 30 on a copy of `main` carrying only this file, 30 of 30 on this branch. The whole spec passes.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Deploying cozy-loop-studio with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5ee4272
Status: ✅  Deploy successful!
Preview URL: https://184778b3.cozy-loop-studio.pages.dev
Branch Preview URL: https://feat-share-link-password.cozy-loop-studio.pages.dev

View logs

Part of #300. Found on an iPhone while checking this pull request's preview.

The phone's result sheet copied the link once, automatically, and offered nothing after that: a status line and a read-only field. A clipboard overwritten since, or a browser that refused the first write, left a long link to be selected by hand. With a protected link the way back is worse than for a plain one, because making it again means typing the password twice.

The sheet now has a button under the field. It says `Copy again` after the automatic copy worked and `Copy` when it did not. It copies the link the sheet is showing and nothing else: no link is made again, and the sheet never holds a password. On success the status line says the link was copied, in a live region whose text node is replaced so it is announced each time. Where the clipboard refuses, the field takes focus with the whole link selected, and the status keeps saying to copy it by hand. A plain and a protected link use the same button.

Desktop and phone now call one function, `copyShareLink`, for every copy of a link. The strings are the desktop panel's existing ones; no translation is added. Keeping the link after the sheet is closed is not part of this change.

Tests, in the phone project: a plain and a protected link are each copied once automatically and three more times by the button, with the same link every time, no second key derivation and no password in any write; a refused first copy shows `Copy`, a second refusal selects the whole link, and a working clipboard then copies and says so; at 320 and 390 px the button is inside the sheet and the screen, at least 44 px tall, and what is under its centre is the button, not the run bar.
@MerciHanrim

Copy link
Copy Markdown
Owner Author

Password managers and the phone's Copy button, observed in real browsers on this pull request's previews. Throwaway passwords only; no offer was accepted.

environment offer creating and opening without accepting
Desktop Chrome 154, throwaway profile, at c4ea6a7 no strong-password suggestion; after a link is created the address bar shows Chrome's save-password icon, no popup; it stays while a link is opened normal; Chrome's store held 0 credentials afterwards; the field is empty when the link is opened again
iPhone 15 Pro, Safari, at 5ee4272 an offer appeared (which kind was not recorded) normal
Nothing Phone (1), Chrome, at 5ee4272 none reported normal
  • On iPhone Safari the automatic copy that follows key derivation is refused by the browser. The sheet says so (Copy this link: with a Copy button), and pressing the button copies the link; it then says the link was copied and offers Copy again. This was seen on the device, and it is why the button was added in 5ee4272.
  • On iPhone Chrome a protected link was created and the result sheet shown; whether that browser offered to save was not recorded.
  • The desktop observation was made by driving a real Chrome over the DevTools protocol without the automation switch and reading the browser's own UI through the accessibility tree; the two phones were operated by hand.
  • Not observed: a signed-in Chrome profile (where a strong-password suggestion may appear), other password managers, and a link opened a second time on the phones.

@MerciHanrim
MerciHanrim merged commit 938aae3 into main Oct 4, 2026
10 checks passed
@MerciHanrim
MerciHanrim deleted the feat/share-link-password branch October 4, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant