CC Safety Net
A coding agent CLI safety layer that blocks destructive git and filesystem commands and secret file access before execution.
Move a secret from one device to another by scanning a QR code. No account, no server of your own, and the relays in between cannot read it.
SecurityTypeScript · KotlinGitHub @sybenxOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Move a secret between two devices by scanning a QR code. Live demo and protocol core.
Translation pending
Taken from the repository README
Loading the full README.
Read the full README ↗Move a secret from one device to another by scanning a QR code. One device shows a code, the other scans it, a person checks five digits across the two screens, and only then does the secret travel, sealed, over public relays that neither device runs and that cannot read it.
Live demo: https://sybenx.github.io/qr-secret-transfer/ · Specification · Profiles
Questions or breakage reports: Nostr: npub175rn…sef5g
This repository is three things:
QR_SECRET_TRANSFER.md, with the
profiles that say what kinds of secret it carries (a Nostr
key, a FROST share, or an app's own, such as a sync key for a game) and its
test vectors. The core mechanism knows nothing about what it moves; a
new kind of secret is a new profile, never a change to the specification.docs/, built from src/demo/). It moves a short
piece of text between two devices. It is a static page: no server of its own, no
analytics, nothing loaded from anywhere else, and no network activity until a
transfer is started.src/core/) with no I/O, no DOM and no timers, plus the browser
adapters that run it over relays (src/web/).kotlin/), a port line for line, for
Android and JVM apps. QRST Signer and Cosign build on it.Status: a draft protocol and unaudited code. Use made-up values, not real secrets. The event kinds are provisional and interoperability is not promised.
npm install
npm run build # writes docs/
npm run relay # a local relay on ws://localhost:7777
npm run serve # the page on http://localhost:8080
Open the page in two browser windows (or on two devices that can both reach the
relay). In each, open This device: relays and past transfers and add
ws://localhost:7777. Then choose Receive a secret in one and Send a secret in
the other.
On the public internet no setup is needed: the page finds relays itself (see Relays).
docs/ is the whole site. On GitHub: Settings → Pages → Deploy from a branch →
main / docs. Any static host works; the page uses only relative paths, so it can
live at a domain root or under a path. This repository's copy is live at
https://sybenx.github.io/qr-secret-transfer/.
The page is its own bounce page (spec §11.2a): the QR codes it shows point back at whatever address it is served from, with every parameter in the fragment, so nothing about a pairing ever reaches the host.
Three <meta> tags in src/demo/index.html are the configuration:
| Tag | Meaning |
|---|---|
qrst:relays | Relays you supply, tried first. content="wss://relay.example.com" |
qrst:seeds | Replaces the starting relays this build ships. content="none" ships none. |
qrst:discovery | Relays to ask for NIP-66 discovery events. content="none" asks nobody extra. |
Before publishing a copy elsewhere, set a canonical URL in src/demo/index.html,
and its GitHub links if your copy of the specification lives somewhere else.
The wire protocol of QR_SECRET_TRANSFER.md 1.6-draft:
vectors/qrst-sas.json);nostr-tools in both directions;qrst: handoff to an installed app (§11.2c), which the bounce
page offers and the paste field accepts;1.6-draft moved the wrap from kind 1059 to 21059 because of this implementation: relays treat kind 1059 as a stored direct message and were seen serving one long after its NIP-40 expiry. A 1.5 client, which uses 1059, cannot pair with this one.
The three levels of §9.2 are chosen on each device with a slider, and the stricter of the two devices' settings applies:
| Level | Sender | Receiver | For |
|---|---|---|---|
| Type digits (high) | types the digits shown on the Receiver | shows five digits | what cannot be taken back |
| Compare digits (medium, default) | shows five digits, the user confirms they match | shows five digits | most things |
| Confirm only (low) | consent only; a second responder ends the session | shows nothing | what can be revoked |
Not implemented: the offline tier (§10), the local network path (§11.7, which a
browser cannot do), profile URI schemes such as frost:// (§12.3), reading the code
by camera (§9.2 "capture"), and the published profiles. The demo carries only its
own text profile, qrst-demo-text (src/core/profile.ts).
1.5-draft was written from this implementation: every place 1.4 could be read two
ways, or was wrong, is recorded under "Resolved" in
SPEC_ISSUES.md. To report a new one,
use the specification form;
a bug in this demo or its code is a
plain issue.
No relay is depended on. Before a device shows a code it sends itself a sealed message, padded to the largest payload the profile allows, through each candidate relay. A relay is named in the code only if that message comes back. That test is exactly what a session needs: the relay accepts a gift wrap from a key it has never seen, serves it to a subscriber it has never seen, and carries a payload of that size.
Candidates are tried in this order: relays you or the page configured; relays that
passed on this device before; relays named by a code this device scanned in a session
that completed; relays that NIP-66 monitors report; and last, a short list of seeds
shipped in src/demo/config.ts. A seed is where a device with nothing else starts.
After the first successful session a device prefers what it remembers, and tops that
memory up from discovery in the background about once a day.
QR_SECRET_TRANSFER.md the specification
profiles/ what kinds of secret it carries, one file each
SPEC_ISSUES.md everything settled about the specification so far
src/core/ pure protocol: no I/O, injected randomness and clock
sas.ts the commitment and the five digits (§6)
nip44.ts NIP-44 v2
event.ts Nostr events, BIP-340, npub
wrap.ts seal and gift wrap, and the attribution check (§11.4)
uri.ts the pairing link (§11.2)
profile.ts the profile interface (§5) and the demo's text profile
session.ts the state machine: flows, consent rules, responders (§7, §8, §9, §13)
src/web/ browser adapters
pool.ts relay links, the re-send loop, NIP-42, the loopback test (§11.5)
select.ts relay selection, NIP-11 limits, NIP-66 discovery
store.ts what a device remembers: relays, throttle, transfer log
transfer.ts one transfer end to end, headless
src/demo/ the page
kotlin/ the Kotlin port of src/core and src/web, as a Gradle build
tools/ a local relay with fault injection, a static server, and
payload_ceiling.py, which computes the wire size of a payload
test/ unit, integration and browser tests
vectors/ the specification's test vectors
docs/ the built site
src/core depends on @noble/curves, @noble/hashes, @noble/ciphers and
@scure/base, and nothing else. The page adds qr for drawing and reading codes.
nostr-tools is a development dependency, used only as a second implementation to
test against.
A session is driven by handing it events and user decisions; it answers with effects for the host to carry out (publish this, keep that, write this record). Nothing in it touches a socket or a clock, which is why a whole flow replays byte for byte in a test.
npm run check # typecheck, unit and integration tests, build, browser tests
test/*.test.ts): the §6 vectors including the transposed-roles negative;
the official NIP-44 vectors; gift wraps against nostr-tools; both flows as state
machines, wrong codes, the attempt budget, expiry, multiple responders, planted
payloads, the throttle, burners wiped at the end.test/relays.test.ts, test/transfer.test.ts): real WebSockets against
a local relay that can refuse writes, swallow events, demand authentication, serve
wraps only to their recipient, advertise limits, answer late, or drop every
connection mid-session.test/retransmit.test.ts): each device in turn loses its connection
as the message meant for it goes out, at every step of both flows, over a relay that
keeps nothing and over one that behaves like strfry (keeps ephemeral events five
minutes, refuses a repeated id and anything over a minute old).kotlin/, ./gradlew :qrst:test on Java 21 after npm ci): the same
vectors, the state machine, and both flows in both directions against the
TypeScript code here, over the local relay.test/e2e): two browser contexts as two devices, through both flows;
the page's own camera reading a code from a fake video feed; the release prompt's
rules; relay selection on screen; and that the page asks nothing of the network
until a transfer starts.Two independent reviews of the code against the specification were run before this
was handed over, one of the core and one of the page. They found one serious fault
(the pairing code could be ground: see "The contacting party gets more than one code
per session" in SPEC_ISSUES.md) and about twenty smaller ones. All are
fixed and have regression tests.
The code is MIT (LICENSE). The specification, its profiles and its
test vectors are CC0 (LICENSE-SPEC). The bundled libraries are MIT and the fonts are
under the SIL Open Font License; their notices ship in docs/.