diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-06-20 01:07:45 +0000 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-06-20 01:07:45 +0000 |
| commit | aa459e3b84cc4c5d908f3781c9253848c18640c3 (patch) | |
| tree | 8454b956cc0fa027034c8291f39d58ece469e10c | |
| download | nymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.tar.gz nymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.tar.xz nymdrop-aa459e3b84cc4c5d908f3781c9253848c18640c3.zip | |
Initial public release: Nym-native anonymous submission system
End-to-end verified pipeline (browser -> HTTP relay -> Nym mixnet -> reader).
Client-side X25519+HKDF+AES-GCM-256, no-log blind relay, AGPL-3.0.
| -rw-r--r-- | .gitignore | 17 | ||||
| -rw-r--r-- | ARCHITECTURE.md | 240 | ||||
| -rw-r--r-- | LICENSE | 661 | ||||
| -rw-r--r-- | README.md | 65 | ||||
| -rw-r--r-- | cmd/nymdrop-reader/main.go | 460 | ||||
| -rw-r--r-- | cmd/nymdrop-source/main.go | 257 | ||||
| -rw-r--r-- | cmd/nymdrop/main.go | 59 | ||||
| -rw-r--r-- | go.mod | 8 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | internal/handler/static.go | 19 | ||||
| -rw-r--r-- | internal/handler/submit.go | 33 | ||||
| -rw-r--r-- | internal/nolog/middleware.go | 15 | ||||
| -rw-r--r-- | internal/nym/client.go | 141 | ||||
| -rw-r--r-- | internal/relay/relay.go | 47 | ||||
| -rwxr-xr-x | scripts/fetch-nym-binaries.sh | 27 | ||||
| -rw-r--r-- | static/crypto.js | 111 | ||||
| -rw-r--r-- | static/index.html | 61 | ||||
| -rw-r--r-- | static/style.css | 179 |
18 files changed, 2404 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..afc30bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Build outputs +/nymdrop +/nymdrop-reader +/nymdrop-source + +# Vendored Nym binaries (fetch with scripts/fetch-nym-binaries.sh) +**/bin/nym-client-linux-amd64 +**/bin/nym-socks5-client-linux-amd64 +/bundle/ + +# Internal working notes (not for publication) +session.md +CLAUDE.md + +# Local artifacts +*.log +*.tmp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..412055f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,240 @@ +# NymDrop — Architecture v2 + +## Principio fondante + +**La protezione è nel design, non nell'obbligo di installare qualcosa. +Chi vuole anonimato aggiuntivo usa quello che trova.** + +La fonte usa un browser qualsiasi. Il documento viaggia interamente su Nym. +Il server è un relay cieco — non conserva nulla, non sa cosa ha trasmesso. + +--- + +## Flusso completo + +``` +[FONTE] [SERVER NYMDROP] [GIORNALISTA] + │ │ │ + │ HTTPS (browser normale) │ │ + ├──────────────────────────────▶│ │ + │ │ │ + │ WebCrypto nel browser: │ + │ cifra con chiave pubblica │ + │ X25519 del giornalista │ + │ │ │ + │ POST ciphertext │ │ + ├──────────────────────────────▶│ │ + │ │ │ + │ riceve ciphertext │ + │ NON lo salva su disco │ + │ │ │ + │ nym-client embedded: │ + │ invia ciphertext al │ + │ Nym address del giornalista │ + │ │ │ + │ │ Nym mixnet (multi-hop) │ + │ │ cover traffic + pad │ + │ ├──────────────────────────▶│ + │ │ │ + │ cancella ciphertext │ + │ dalla RAM │ + │ │ ciphertext arriva + │ │ al Nym address + │ │ del giornalista + │ │ │ + │ │ giornalista decifra + │ │ localmente con + │ │ chiave privata X25519 + │ │ │ + │ conferma generica "ok" │ │ + │◀──────────────────────────────│ │ +``` + +--- + +## Componenti + +### 1. Frontend (browser della fonte) + +- HTML + CSS puro, zero framework JS, zero CDN, zero cookie +- **WebCrypto API** nativa del browser — nessuna libreria esterna: + - Chiave pubblica X25519 del giornalista embedded nel HTML al momento del deploy + - Genera keypair effimera X25519 per la sessione + - ECDH key exchange → shared secret + - Deriva chiave ChaCha20-Poly1305 via HKDF + - Cifra testo + file nel browser + - Manda solo ciphertext + public key effimera al server +- Il plaintext non lascia mai il browser in chiaro +- Form: textarea testo libero + upload file opzionale + submit +- Funziona su qualsiasi browser moderno + +### 2. Backend Go — relay cieco + +``` +nymdrop/ +├── cmd/ +│ └── nymdrop/ +│ └── main.go ← entrypoint +├── internal/ +│ ├── handler/ +│ │ ├── submit.go ← POST /submit +│ │ └── static.go ← serve HTML/CSS/JS +│ ├── relay/ +│ │ └── relay.go ← ricevi → invia su Nym → cancella +│ ├── nym/ +│ │ └── client.go ← wrapper nym client embedded +│ └── nolog/ +│ └── middleware.go ← strip IP, UA, headers — zero log +├── static/ +│ ├── index.html ← form submission +│ └── crypto.js ← WebCrypto lato client +├── go.mod +└── CLAUDE.md +``` + +**Comportamento del relay:** +``` +ricevi ciphertext da HTTP POST + ↓ +passa a nym/client.go → Send(journalistNymAddress, ciphertext) + ↓ +Nym mixnet consegna al giornalista + ↓ +cancella ciphertext dalla memoria (zeroing) + ↓ +rispondi HTTP 200 "ok" +``` + +Zero scritture su disco. Zero database. Zero log. + +### 3. Nym client embedded lato server + +Il server incorpora `nym-socks5-client` come subprocess gestito: + +```go +// nym/client.go +type NymClient struct { + addr string // Nym address del giornalista + process *exec.Cmd + socks5 string // localhost:1080 +} + +func (c *NymClient) Send(payload []byte) error { + // connessione SOCKS5 → Nym mixnet → giornalista + conn, _ := proxy.SOCKS5("tcp", c.socks5, nil, proxy.Direct) + // ... +} +``` + +Evoluzione futura: FFI bindings Rust/Go via `sdk/ffi/shared/` — client in-process, zero subprocess. + +### 4. Lato giornalista + +Il giornalista ha un **Nym address** dedicato a NymDrop. +Riceve i documenti cifrati direttamente sul suo client Nym (NymConnect o nym-socks5-client locale). +Decifra localmente con la sua chiave privata X25519 — il server non è mai coinvolto nella decifrazione. + +Nessuna interfaccia admin sul server — il server non ha nulla da mostrare perché non conserva nulla. + +--- + +## No-log by design + +```go +func NoLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Del("X-Forwarded-For") + r.Header.Del("X-Real-IP") + r.Header.Del("User-Agent") + r.RemoteAddr = "0.0.0.0:0" + next.ServeHTTP(w, r) + }) +} +``` + +- Zero access log +- Zero error log con IP +- Zero timestamp salvati +- Rate limiting via token bucket anonimo (contatore in RAM, reset periodico) + +--- + +## Threat model + +| Minaccia | Protetto? | Come | +|---|---|---| +| Server legge documento | ✅ | Cifrato WebCrypto lato client — server vede solo ciphertext | +| Server logga IP fonte | ✅ | No-log middleware, zero access log | +| Sequestro server | ✅ | Zero documenti su disco — relay puro, inbox sempre vuota | +| Traffic analysis fonte→server | ⚠️ | HTTPS normale — fonte usa quello che trova | +| Traffic analysis server→giornalista | ✅ | Nym mixnet multi-hop, cover traffic, padding | +| Correlazione timing | ✅ | Nym Loopix delays randomizzati | +| Intercettazione documento in transito | ✅ | ChaCha20-Poly1305, solo destinatario decifra | +| Compromissione chiave privata giornalista | ❌ | Fuori scope — responsabilità del giornalista | + +**Punto chiave per il grant:** sequestro del server = zero documenti recuperabili. Il server è matematicamente incapace di rivelare il contenuto delle submission — non perché lo voglia, ma perché non lo ha mai avuto. + +--- + +## Deploy single container + +```bash +# Proxmox — CT125 +pct create 125 /var/lib/vz/template/cache/debian-12.tar.zst \ + --hostname nymdrop --memory 512 --cores 1 + +# Inside container +apt install -y golang-go +git clone https://github.com/gabrix73/nymdrop +cd nymdrop +go build ./cmd/nymdrop + +# Config +cat > /etc/nymdrop/config.toml << EOF +listen = ":443" +journalist_nym_address = "<NYM_ADDRESS>" +journalist_pubkey = "<X25519_PUBLIC_KEY_HEX>" +tls_cert = "/etc/ssl/nymdrop/cert.pem" +tls_key = "/etc/ssl/nymdrop/key.pem" +EOF + +./nymdrop --config /etc/nymdrop/config.toml +``` + +--- + +## Struttura repo + +``` +gabrix73/nymdrop +├── cmd/nymdrop/ +├── internal/ +├── static/ +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── THREAT_MODEL.md +│ └── DEPLOY.md +├── CLAUDE.md +├── LICENSE ← AGPL-3.0 +├── README.md +└── go.mod +``` + +--- + +## Pitch grant (sintesi) + +NymDrop è il primo sistema di whistleblowing dove: +- La fonte usa un browser qualsiasi — zero installazioni +- Il documento viaggia end-to-end su Nym mixnet +- Il server è un relay cieco — zero storage, zero metadati +- Sequestro del server = zero documenti recuperabili +- Resistente a traffic analysis globale (Loopix delays, cover traffic) + +Nessun sistema esistente combina questi quattro punti simultaneamente. + +--- + +## Prossimo passo + +Scaffolding Go: `main.go` + `nolog/middleware.go` + `handler/submit.go` + `static/index.html` + `static/crypto.js` @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc517ae --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# NymDrop + +A modern, Nym-native anonymous submission system — a SecureDrop alternative that +uses the [Nym mixnet](https://nymtech.net/) as its transport instead of Tor. + +The source uses an ordinary browser. There is nothing to install, no onion +service to reach, and no client-side configuration. Anonymity protection lives in +the design, not in an obligation on the source to install anything. + +## Design + +- **Transport:** Nym mixnet only — no Tor. Cover traffic, padding and mixnet + unlinkability protect the server side. +- **Client-side encryption:** submissions are encrypted in the browser with + X25519 + HKDF-SHA-256 + AES-GCM-256 (WebCrypto). The server never sees + plaintext. +- **No-log server:** no IP, no timestamps, no metadata persisted. A blind relay + forwards ciphertext through the mixnet and keeps nothing on disk. +- **No third parties:** no CDN, no Cloudflare, no NIST curves, no tracking + JavaScript. + +See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the full threat model and component +breakdown. + +## Components + +| Binary | Role | +|---|---| +| `nymdrop` | Public-facing HTTP server + blind relay. Embeds a `nym-client`, accepts encrypted submissions on `POST /submit`, forwards them through the mixnet to the reader's Nym address. | +| `nymdrop-reader` | Operator-side inbox. Runs a `nym-client`, receives messages from the mixnet, decrypts them with the operator's X25519 private key, and writes submissions to disk. | +| `nymdrop-source` | Optional SOCKS5-based source helper. | + +## Build + +The `nym-client` binary is required at build time (embedded via `go:embed`) but +is **not** committed to keep the repository small. Fetch it first: + +```sh +./scripts/fetch-nym-binaries.sh # downloads nym-client v1.1.76 (linux/amd64) + +go build ./cmd/nymdrop/ +go build ./cmd/nymdrop-reader/ +``` + +## Run + +```sh +# 1. Start the reader (prints its Nym inbox address and the public key to deploy) +./nymdrop-reader --id nymdrop-inbox --ws-port 1977 + +# 2. Start the server, pointing it at the reader's Nym address +./nymdrop --journalist "<NYM_ADDRESS_FROM_READER>" --static ./static --listen 127.0.0.1:8080 +``` + +Deploy the reader's public key into `static/crypto.js` so the browser encrypts to +the right key. Put the server behind TLS in production. + +## Status + +End-to-end verified: browser/client → HTTP server → Nym mixnet → reader → +decrypted submission on disk. + +## License + +[AGPL-3.0](LICENSE). diff --git a/cmd/nymdrop-reader/main.go b/cmd/nymdrop-reader/main.go new file mode 100644 index 0000000..248972e --- /dev/null +++ b/cmd/nymdrop-reader/main.go @@ -0,0 +1,460 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + + "golang.org/x/crypto/hkdf" + "embed" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "golang.org/x/net/websocket" +) + +//go:embed bin/nym-client-linux-amd64 +var nymBin embed.FS + +func main() { + keyFile := flag.String("key", "", "path to X25519 private key file (hex, 32 bytes); generated if missing") + inboxID := flag.String("id", "nymdrop-inbox", "nym-client identity id") + wsPort := flag.Int("ws-port", 1977, "nym-client websocket port") + outDir := flag.String("out", "", "directory to save submissions (default: ~/nymdrop-inbox)") + flag.Parse() + + homeDir, _ := os.UserHomeDir() + if *outDir == "" { + *outDir = filepath.Join(homeDir, "nymdrop-inbox") + } + if err := os.MkdirAll(*outDir, 0700); err != nil { + fatalf("mkdir outDir: %v", err) + } + + privKey := loadOrGenKey(*keyFile, homeDir) + + nymBinPath := extractNymClient() + defer os.RemoveAll(filepath.Dir(nymBinPath)) + + configDir := filepath.Join(homeDir, ".nymdrop-reader") + initNymClient(nymBinPath, *inboxID, configDir) + + nymCmd := startNymClient(nymBinPath, *inboxID, configDir, *wsPort) + defer nymCmd.Process.Kill() + + wsURL := fmt.Sprintf("ws://127.0.0.1:%d", *wsPort) + ws := dialWS(wsURL) + defer ws.Close() + + // Print own Nym address so operator knows where to point sources. + printSelfAddress(ws) + + fmt.Printf("nymdrop-reader listening — submissions saved to %s\n", *outDir) + fmt.Println("Press Ctrl+C to quit.") + + go receiveLoop(ws, privKey, *outDir) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + fmt.Println("\nShutting down.") +} + +// ── key management ─────────────────────────────────────────────────────────── + +func loadOrGenKey(keyFile, homeDir string) *ecdh.PrivateKey { + if keyFile == "" { + keyFile = filepath.Join(homeDir, ".nymdrop-reader", "privkey.hex") + } + data, err := os.ReadFile(keyFile) + if err == nil { + raw, err := hex.DecodeString(strings.TrimSpace(string(data))) + if err != nil { + fatalf("privkey decode: %v", err) + } + priv, err := ecdh.X25519().NewPrivateKey(raw) + if err != nil { + fatalf("privkey load: %v", err) + } + fmt.Printf("Loaded private key from %s\n", keyFile) + pubHex := hex.EncodeToString(priv.PublicKey().Bytes()) + fmt.Printf("Public key (deploy in nymdrop-server): %s\n", pubHex) + return priv + } + + // Generate new key. + rawPriv := make([]byte, 32) + if _, err := rand.Read(rawPriv); err != nil { + fatalf("keygen rand: %v", err) + } + priv, err := ecdh.X25519().NewPrivateKey(rawPriv) + if err != nil { + fatalf("keygen: %v", err) + } + if err := os.MkdirAll(filepath.Dir(keyFile), 0700); err != nil { + fatalf("mkdir keydir: %v", err) + } + if err := os.WriteFile(keyFile, []byte(hex.EncodeToString(rawPriv)+"\n"), 0600); err != nil { + fatalf("write privkey: %v", err) + } + pubHex := hex.EncodeToString(priv.PublicKey().Bytes()) + fmt.Printf("Generated new private key → %s\n", keyFile) + fmt.Printf("Public key (deploy in nymdrop-server): %s\n\n", pubHex) + fmt.Println(" Set NYMDROP_PUBKEY_PLACEHOLDER to the public key above in static/crypto.js") + fmt.Println(" and rebuild the server before accepting submissions.") + return priv +} + +// ── nym-client lifecycle ───────────────────────────────────────────────────── + +func extractNymClient() string { + tmpDir, err := os.MkdirTemp("", "nymdrop-reader-*") + if err != nil { + fatalf("mktemp: %v", err) + } + binName := "nym-client" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(tmpDir, binName) + + srcName := "bin/nym-client-linux-amd64" + data, err := fs.ReadFile(nymBin, srcName) + if err != nil { + fatalf("embedded nym-client not found: %v", err) + } + if err := os.WriteFile(binPath, data, 0700); err != nil { + fatalf("extract nym-client: %v", err) + } + return binPath +} + +func initNymClient(binPath, id, configDir string) { + // --home removed: nym-client v1.1.76+ uses ~/.nym/ fixed path + cmd := exec.Command(binPath, "init", "--id", id) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} + +func startNymClient(binPath, id, configDir string, wsPort int) *exec.Cmd { + cmd := exec.Command(binPath, "run", + "--id", id, + "--port", fmt.Sprintf("%d", wsPort), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + fatalf("nym-client start: %v", err) + } + return cmd +} + +// dialWS waits for the nym-client WebSocket to be ready, then returns the +// open connection. Reusing the probe connection avoids the duplicate-connection +// panic in nym-client (which rejects a second WS on the same port). +func dialWS(wsURL string) *websocket.Conn { + fmt.Print("Connecting to Nym mixnet") + for i := 0; i < 120; i++ { + ws, err := websocket.Dial(wsURL, "", "http://localhost/") + if err == nil { + fmt.Println(" ready.") + return ws + } + fmt.Print(".") + time.Sleep(500 * time.Millisecond) + } + fatalf("nym-client websocket did not start in time") + return nil +} + +// ── websocket message types ─────────────────────────────────────────────────── + +type wsReceived struct { + Type string `json:"type"` + Message json.RawMessage `json:"message"` + SenderTag string `json:"senderTag"` +} + +type wsSelfAddress struct { + Type string `json:"type"` + Address string `json:"address"` +} + +func printSelfAddress(ws *websocket.Conn) { + req := map[string]string{"type": "selfAddress"} + if err := websocket.JSON.Send(ws, req); err != nil { + fmt.Fprintf(os.Stderr, "selfAddress send: %v\n", err) + return + } + var resp wsSelfAddress + if err := websocket.JSON.Receive(ws, &resp); err != nil { + fmt.Fprintf(os.Stderr, "selfAddress recv: %v\n", err) + return + } + fmt.Printf("Nym inbox address: %s\n\n", resp.Address) +} + +// ── receive loop ────────────────────────────────────────────────────────────── + +func receiveLoop(ws *websocket.Conn, privKey *ecdh.PrivateKey, outDir string) { + debug := os.Getenv("NYMDROP_DEBUG") != "" + for { + // Read the raw frame regardless of opcode. websocket.Message.Receive + // copies the payload of both text and binary frames into the []byte, + // whereas websocket.JSON.Receive silently drops anything that is not a + // well-formed JSON text frame — which is exactly how earlier received + // messages were being lost. + var frame []byte + if err := websocket.Message.Receive(ws, &frame); err != nil { + if err == io.EOF || strings.Contains(err.Error(), "use of closed network connection") { + return + } + fmt.Fprintf(os.Stderr, "ws recv: %v\n", err) + time.Sleep(500 * time.Millisecond) + continue + } + if len(frame) == 0 { + continue + } + if debug { + preview := frame + if len(preview) > 200 { + preview = preview[:200] + } + fmt.Fprintf(os.Stderr, "[debug] frame: %d bytes, lead=0x%02x: %q\n", + len(frame), frame[0], preview) + } + + dataB64, ok := extractMessageB64(frame) + if !ok { + continue + } + + plaintext, err := decryptMessage(dataB64, privKey) + if err != nil { + fmt.Fprintf(os.Stderr, "decrypt failed: %v\n", err) + continue + } + + saveSubmission(outDir, plaintext) + } +} + +// extractMessageB64 pulls the base64 payload string out of a nym-client +// "received" frame. It tolerates the two encodings nym-client uses across +// versions: text/JSON (the common case) and the native binary protocol. +func extractMessageB64(frame []byte) (string, bool) { + // Text/JSON frame: {"type":"received","message":"<base64>","senderTag":...} + if frame[0] == '{' { + var msg wsReceived + if err := json.Unmarshal(frame, &msg); err != nil || msg.Type != "received" { + return "", false + } + // message is normally a JSON string; tolerate the legacy + // {"data":"<base64>"} object shape just in case. + var s string + if err := json.Unmarshal(msg.Message, &s); err == nil { + return s, true + } + var obj struct { + Data string `json:"data"` + } + if err := json.Unmarshal(msg.Message, &obj); err == nil && obj.Data != "" { + return obj.Data, true + } + return "", false + } + + // Binary frame (nym native binary protocol): + // 0x01 (Received tag) | senderTag flag(1) | [senderTag 16 bytes] | message + // The message bytes are exactly what the sender transmitted — for nymdrop + // that is the base64 ASCII string produced by the server. + if frame[0] == 0x01 { + buf := frame[1:] + if len(buf) < 1 { + return "", false + } + hasTag := buf[0] + buf = buf[1:] + if hasTag == 1 { + if len(buf) < 16 { + return "", false + } + buf = buf[16:] + } + if len(buf) == 0 { + return "", false + } + return string(buf), true + } + + return "", false +} + +// ── decryption ──────────────────────────────────────────────────────────────── + +// decryptMessage decrypts a base64-encoded packet from the relay. +// +// Wire format from relay: +// nonce_hex(32 ASCII chars) + ":" + ephemeral_x25519_pub(32) + iv(12) + ciphertext+tag +// +// The nonce prefix is stripped; it exists only to make identical submissions +// look different on the Nym wire. It is not used in decryption. +func decryptMessage(dataB64 string, privKey *ecdh.PrivateKey) (string, error) { + raw, err := base64.StdEncoding.DecodeString(dataB64) + if err != nil { + return "", fmt.Errorf("base64: %w", err) + } + + // Strip relay nonce prefix: 32 hex chars + ':' + const noncePrefix = 32 + 1 // "deadbeef...:" = 33 bytes + if len(raw) < noncePrefix+32+12+1 { + return "", fmt.Errorf("packet too short (%d bytes)", len(raw)) + } + raw = raw[noncePrefix:] + + // Extract ephemeral public key (32 bytes X25519) + ephPubBytes := raw[:32] + iv := raw[32:44] + ciphertext := raw[44:] + + ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes) + if err != nil { + return "", fmt.Errorf("ephemeral pubkey: %w", err) + } + + // ECDH + sharedSecret, err := privKey.ECDH(ephPub) + if err != nil { + return "", fmt.Errorf("ecdh: %w", err) + } + defer zeroBytes(sharedSecret) + + // HKDF-SHA-256 + aesKey := make([]byte, 32) + hkdfR := hkdf.New(sha256.New, sharedSecret, make([]byte, 32), []byte("nymdrop-v1")) + if _, err := io.ReadFull(hkdfR, aesKey); err != nil { + return "", fmt.Errorf("hkdf: %w", err) + } + defer zeroBytes(aesKey) + + // AES-GCM-256 decrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("aes: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("gcm: %w", err) + } + plaintext, err := gcm.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("gcm open: %w", err) + } + + return string(plaintext), nil +} + +// ── storage ─────────────────────────────────────────────────────────────────── + +func saveSubmission(outDir, plaintext string) { + ts := time.Now().UTC().Format("20060102-150405") + path := filepath.Join(outDir, ts+".txt") + + // Avoid collision on rapid submissions. + for i := 1; ; i++ { + if _, err := os.Stat(path); os.IsNotExist(err) { + break + } + path = filepath.Join(outDir, fmt.Sprintf("%s-%d.txt", ts, i)) + } + + // Separate embedded files from text body. + body := plaintext + fileSection := "" + if idx := strings.Index(plaintext, "\n---FILE:"); idx != -1 { + body = plaintext[:idx] + fileSection = plaintext[idx+1:] + } + + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + fmt.Fprintf(os.Stderr, "save submission: %v\n", err) + return + } + fmt.Printf("\n[%s] New submission → %s\n", ts, path) + preview := body + if len(preview) > 200 { + preview = preview[:200] + "..." + } + fmt.Printf(" %s\n", strings.ReplaceAll(strings.TrimSpace(preview), "\n", "\n ")) + + // Save attached files. + if fileSection != "" { + saveAttachments(outDir, ts, fileSection) + } +} + +func saveAttachments(outDir, ts, fileSection string) { + lines := strings.Split(fileSection, "\n") + var currentName string + var currentData strings.Builder + + flush := func() { + if currentName == "" { + return + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(currentData.String())) + if err != nil { + fmt.Fprintf(os.Stderr, " attachment decode %q: %v\n", currentName, err) + return + } + safeName := filepath.Base(filepath.Clean(currentName)) + attPath := filepath.Join(outDir, ts+"-"+safeName) + if err := os.WriteFile(attPath, raw, 0600); err != nil { + fmt.Fprintf(os.Stderr, " save attachment %q: %v\n", currentName, err) + return + } + fmt.Printf(" attachment → %s (%d bytes)\n", attPath, len(raw)) + currentName = "" + currentData.Reset() + } + + for _, line := range lines { + if strings.HasPrefix(line, "---FILE:") && strings.HasSuffix(line, "---") { + flush() + currentName = strings.TrimSuffix(strings.TrimPrefix(line, "---FILE:"), "---") + } else if currentName != "" { + currentData.WriteString(line) + } + } + flush() +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "nymdrop-reader: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/nymdrop-source/main.go b/cmd/nymdrop-source/main.go new file mode 100644 index 0000000..a9ff0f5 --- /dev/null +++ b/cmd/nymdrop-source/main.go @@ -0,0 +1,257 @@ +package main + +import ( + "embed" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "syscall" + "time" + + "golang.org/x/net/proxy" +) + +//go:embed bin/nym-socks5-client-linux-amd64 +var nymBin embed.FS + +// nymdropProviderAddr is the Nym network requester — set at build time via -ldflags +var nymdropProviderAddr = "NYMDROP_PROVIDER_ADDR_PLACEHOLDER" + +// nymdropInboxAddr is the Nym address of the nymdrop-server SP — set at build time via -ldflags +var nymdropInboxAddr = "NYMDROP_INBOX_ADDR_PLACEHOLDER" + +func main() { + // Extract nym-socks5-client binary to temp dir + tmpDir, err := os.MkdirTemp("", "nymdrop-*") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(tmpDir) + + nymBinName := "nym-socks5-client" + if runtime.GOOS == "windows" { + nymBinName += ".exe" + } + nymBinPath := filepath.Join(tmpDir, nymBinName) + + srcName := "bin/nym-socks5-client-linux-amd64" + data, err := fs.ReadFile(nymBin, srcName) + if err != nil { + fmt.Fprintf(os.Stderr, "embedded binary not found: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(nymBinPath, data, 0700); err != nil { + fmt.Fprintf(os.Stderr, "extract binary: %v\n", err) + os.Exit(1) + } + + // Init nym client config if not already done + homeDir, _ := os.UserHomeDir() + configDir := filepath.Join(homeDir, ".nymdrop-source") + initCmd := exec.Command(nymBinPath, "init", "--id", "nymdrop-source", + "--provider", nymdropProviderAddr, + "--home", configDir) + initCmd.Stdout = os.Stdout + initCmd.Stderr = os.Stderr + _ = initCmd.Run() // ignore error if already initialised + + // Start nym-socks5-client + nymCmd := exec.Command(nymBinPath, "run", "--id", "nymdrop-source", + "--home", configDir, + "--port", "11080") + nymCmd.Stdout = os.Stdout + nymCmd.Stderr = os.Stderr + if err := nymCmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "nym start: %v\n", err) + os.Exit(1) + } + defer nymCmd.Process.Kill() + + // Wait for SOCKS5 to be ready + fmt.Println("Connecting to Nym mixnet...") + for i := 0; i < 30; i++ { + conn, err := net.DialTimeout("tcp", "127.0.0.1:11080", 300*time.Millisecond) + if err == nil { + conn.Close() + break + } + time.Sleep(500 * time.Millisecond) + } + + // Serve submission form locally + mux := http.NewServeMux() + mux.HandleFunc("/", serveForm) + mux.HandleFunc("/submit", handleSubmit) + + srv := &http.Server{Addr: "127.0.0.1:18080", Handler: mux} + go srv.ListenAndServe() + + // Open browser + openBrowser("http://127.0.0.1:18080") + fmt.Println("NymDrop ready — http://127.0.0.1:18080") + + // Wait for interrupt + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + fmt.Println("\nShutting down.") +} + +func openBrowser(url string) { + switch runtime.GOOS { + case "linux": + exec.Command("xdg-open", url).Start() + case "darwin": + exec.Command("open", url).Start() + case "windows": + exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + } +} + +func serveForm(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, submissionForm) +} + +func handleSubmit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) + defer r.Body.Close() + + payload, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + defer func() { + for i := range payload { + payload[i] = 0 + } + }() + + if len(payload) == 0 { + http.Error(w, "empty payload", http.StatusBadRequest) + return + } + + dialer, err := proxy.SOCKS5("tcp", "127.0.0.1:11080", nil, proxy.Direct) + if err != nil { + fmt.Fprintf(os.Stderr, "socks5 dialer: %v\n", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + conn, err := dialer.Dial("tcp", nymdropInboxAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "nym dial: %v\n", err) + http.Error(w, "delivery failed", http.StatusBadGateway) + return + } + defer conn.Close() + + if _, err := conn.Write(payload); err != nil { + fmt.Fprintf(os.Stderr, "nym send: %v\n", err) + http.Error(w, "delivery failed", http.StatusBadGateway) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +const submissionForm = `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>NymDrop — Secure Submission</title> +<style> +*{box-sizing:border-box;margin:0;padding:0} +body{background:#0d1117;color:#c9d1d9;font-family:'Courier New',monospace;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem 1rem} +.card{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:2.5rem 2rem;width:100%;max-width:580px} +h1{font-size:1.4rem;color:#e6edf3;letter-spacing:.15em;margin-bottom:.3rem} +.sub{font-size:.72rem;color:#484f58;margin-bottom:1.5rem} +.badges{display:flex;gap:.5rem;margin-bottom:1.8rem;flex-wrap:wrap} +.badge{font-size:.65rem;padding:.2rem .6rem;border-radius:20px;font-weight:bold} +.bg{background:rgba(0,255,160,.08);color:#00ffa0;border:1px solid rgba(0,255,160,.2)} +.bb{background:rgba(0,128,255,.08);color:#58a6ff;border:1px solid rgba(0,128,255,.2)} +.bn{background:rgba(255,255,255,.04);color:#8b949e;border:1px solid #30363d} +label{display:block;font-size:.72rem;color:#8b949e;margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.08em} +textarea{width:100%;height:160px;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-family:'Courier New',monospace;font-size:.88rem;padding:.9rem;resize:vertical;margin-bottom:1.4rem;outline:none} +textarea:focus{border-color:#00ffa0} +.fl{margin-bottom:1.8rem} +.fl-btn{display:inline-block;background:#21262d;border:1px solid #30363d;border-radius:6px;color:#8b949e;font-size:.78rem;padding:.5rem 1rem;cursor:pointer} +#file{display:none} +#fn{font-size:.72rem;color:#58a6ff;margin-left:.6rem} +button{width:100%;background:linear-gradient(135deg,#00ffa0,#0080ff);border:none;border-radius:6px;color:#0d1117;font-family:'Courier New',monospace;font-size:.9rem;font-weight:bold;letter-spacing:.1em;padding:.85rem;cursor:pointer;text-transform:uppercase} +#st{margin-top:1rem;font-size:.78rem;text-align:center;color:#00ffa0;min-height:1.2rem} +hr{border:none;border-top:1px solid #21262d;margin:2rem 0 1.2rem} +.note{font-size:.68rem;color:#3d444d;line-height:1.7} +</style> +</head> +<body> +<div class="card"> +<h1>⬡ NYMDROP</h1> +<p class="sub">Anonymous submission — encrypted in your browser, delivered over Nym mixnet.</p> +<div class="badges"> +<span class="badge bg">END-TO-END ENCRYPTED</span> +<span class="badge bb">NYM MIXNET</span> +<span class="badge bn">NO LOGS</span> +<span class="badge bn">NO METADATA</span> +</div> +<form id="f"> +<label for="msg">Message</label> +<textarea id="msg" placeholder="Write your message here..."></textarea> +<div class="fl"> +<label>Attachment (optional)</label> +<label class="fl-btn" for="file">📎 Choose file</label> +<input type="file" id="file"> +<span id="fn"></span> +</div> +<button type="submit">🔒 Submit securely</button> +</form> +<div id="st"></div> +<hr> +<p class="note">Your submission is encrypted before leaving your device. The server keeps no logs. Once delivered over Nym, no record exists on this server.</p> +</div> +<script> +document.getElementById('file').onchange=function(){document.getElementById('fn').textContent=this.files.length?this.files[0].name:''}; +const PUB='NYMDROP_PUBKEY_PLACEHOLDER'; +async function h2b(h){const b=new Uint8Array(h.length/2);for(let i=0;i<h.length;i+=2)b[i/2]=parseInt(h.substr(i,2),16);return b} +document.getElementById('f').onsubmit=async function(e){ +e.preventDefault(); +const st=document.getElementById('st'); +st.className='';st.textContent='Encrypting...'; +try{ +let pt=document.getElementById('msg').value; +const fi=document.getElementById('file'); +if(fi.files.length){const fb=await fi.files[0].arrayBuffer();pt+='\n---FILE:'+fi.files[0].name+'---\n'+btoa(String.fromCharCode(...new Uint8Array(fb)))} +const sk=await crypto.subtle.importKey('raw',await h2b(PUB),{name:'ECDH',namedCurve:'X25519'},false,[]); +const ep=await crypto.subtle.generateKey({name:'ECDH',namedCurve:'X25519'},true,['deriveKey','deriveBits']); +const sb=await crypto.subtle.deriveBits({name:'ECDH',public:sk},ep.privateKey,256); +const hk=await crypto.subtle.importKey('raw',sb,'HKDF',false,['deriveKey']); +const ak=await crypto.subtle.deriveKey({name:'HKDF',hash:'SHA-256',salt:new Uint8Array(32),info:new TextEncoder().encode('nymdrop-v1')},hk,{name:'AES-GCM',length:256},false,['encrypt']); +const iv=crypto.getRandomValues(new Uint8Array(12)); +const ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},ak,new TextEncoder().encode(pt)); +const er=await crypto.subtle.exportKey('raw',ep.publicKey); +const pkt=new Uint8Array(32+12+ct.byteLength); +pkt.set(new Uint8Array(er),0);pkt.set(iv,32);pkt.set(new Uint8Array(ct),44); +const r=await fetch('/submit',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:pkt}); +if(r.ok){document.getElementById('f').reset();document.getElementById('fn').textContent='';st.textContent='Delivered. No record of this submission exists on the server.'} +else{st.className='error';st.textContent='Delivery failed. Try again.';} +}catch(err){st.className='error';st.textContent='Encryption failed: '+err.message}}; +</script> +</body> +</html>` diff --git a/cmd/nymdrop/main.go b/cmd/nymdrop/main.go new file mode 100644 index 0000000..9119b9c --- /dev/null +++ b/cmd/nymdrop/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "embed" + "flag" + "log" + "net/http" + "nymdrop/internal/handler" + "nymdrop/internal/nolog" + "nymdrop/internal/nym" + "nymdrop/internal/relay" +) + +//go:embed bin/nym-client-linux-amd64 +var nymBin embed.FS + +func main() { + listen := flag.String("listen", ":8080", "address to listen on") + tlsCert := flag.String("tls-cert", "", "TLS certificate path (optional)") + tlsKey := flag.String("tls-key", "", "TLS key path (optional)") + journalistAddr := flag.String("journalist", "", "journalist Nym address (required)") + staticDir := flag.String("static", "./static", "path to static files") + dryRun := flag.Bool("dry-run", false, "skip Nym, log payloads to stdout (testing only)") + flag.Parse() + + if *journalistAddr == "" { + log.Fatal("--journalist is required") + } + + var nymClient *nym.Client + if *dryRun { + nymClient = nym.NewDryRun(*journalistAddr) + } else { + nymClient = nym.New(*journalistAddr, nymBin) + } + if err := nymClient.Start(); err != nil { + log.Fatalf("nym client: %v", err) + } + defer nymClient.Stop() + + r := relay.New(nymClient) + + mux := http.NewServeMux() + mux.Handle("/submit", handler.NewSubmit(r)) + mux.Handle("/", handler.Static(*staticDir)) + + srv := &http.Server{ + Addr: *listen, + Handler: nolog.Middleware(mux), + } + + if *tlsCert != "" && *tlsKey != "" { + log.Printf("nymdrop listening on %s (TLS)", *listen) + log.Fatal(srv.ListenAndServeTLS(*tlsCert, *tlsKey)) + } else { + log.Printf("nymdrop listening on %s (plain HTTP — use TLS in production)", *listen) + log.Fatal(srv.ListenAndServe()) + } +} @@ -0,0 +1,8 @@ +module nymdrop + +go 1.26.2 + +require ( + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.54.0 // indirect +) @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= diff --git a/internal/handler/static.go b/internal/handler/static.go new file mode 100644 index 0000000..bf2aaec --- /dev/null +++ b/internal/handler/static.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" +) + +func Static(dir string) http.Handler { + fs := http.FileServer(http.Dir(dir)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Security headers on every response. + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Cache-Control", "no-store") + fs.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/submit.go b/internal/handler/submit.go new file mode 100644 index 0000000..15251cf --- /dev/null +++ b/internal/handler/submit.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "nymdrop/internal/relay" +) + +type Submit struct { + relay *relay.Relay +} + +func NewSubmit(r *relay.Relay) *Submit { + return &Submit{relay: r} +} + +func (s *Submit) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) + defer r.Body.Close() + + if err := s.relay.Forward(r.Body); err != nil { + // Generic error — reveal nothing about internals. + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/internal/nolog/middleware.go b/internal/nolog/middleware.go new file mode 100644 index 0000000..c11ed55 --- /dev/null +++ b/internal/nolog/middleware.go @@ -0,0 +1,15 @@ +package nolog + +import "net/http" + +// Strip all identifying information from every request before any handler sees it. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Del("X-Forwarded-For") + r.Header.Del("X-Real-IP") + r.Header.Del("User-Agent") + r.Header.Del("Referer") + r.RemoteAddr = "0.0.0.0:0" + next.ServeHTTP(w, r) + }) +} diff --git a/internal/nym/client.go b/internal/nym/client.go new file mode 100644 index 0000000..89fcf52 --- /dev/null +++ b/internal/nym/client.go @@ -0,0 +1,141 @@ +package nym + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "golang.org/x/net/websocket" +) + +// Client sends payloads through the Nym mixnet via an embedded nym-client subprocess. +type Client struct { + journalistAddr string + wsPort int + dryRun bool + process *exec.Cmd + ws *websocket.Conn + binFS fs.FS + configDir string +} + +func New(journalistAddr string, binFS fs.FS) *Client { + return &Client{journalistAddr: journalistAddr, wsPort: 1978, binFS: binFS} +} + +func NewDryRun(journalistAddr string) *Client { + return &Client{journalistAddr: journalistAddr, dryRun: true} +} + +// Start extracts the nym-client binary, inits config, starts the subprocess, +// and connects to its WebSocket. No-op in dry-run mode. +func (c *Client) Start() error { + if c.dryRun { + fmt.Printf("[dry-run] Nym client skipped — journalist: %s\n", c.journalistAddr) + return nil + } + + binPath, tmpDir, err := extractBin(c.binFS) + if err != nil { + return fmt.Errorf("extract nym-client: %w", err) + } + + homeDir, _ := os.UserHomeDir() + c.configDir = filepath.Join(homeDir, ".nymdrop-server") + + // Init (idempotent — ignore error if already initialised). + exec.Command(binPath, "init", "--id", "nymdrop-server").Run() + + cmd := exec.Command(binPath, "run", "--id", "nymdrop-server", + "--port", fmt.Sprintf("%d", c.wsPort)) + cmd.Stdout = os.Stderr // route nym-client noise to our stderr + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + os.RemoveAll(tmpDir) + return fmt.Errorf("nym-client start: %w", err) + } + c.process = cmd + + wsURL := fmt.Sprintf("ws://127.0.0.1:%d", c.wsPort) + for i := 0; i < 120; i++ { + ws, err := websocket.Dial(wsURL, "", "http://localhost/") + if err == nil { + c.ws = ws + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("nym-client websocket did not start in time") +} + +type sendMsg struct { + Type string `json:"type"` + Message string `json:"message"` + Recipient string `json:"recipient"` + WithReplySurb bool `json:"withReplySurb"` +} + +// Send delivers payload to the journalist's Nym address through the mixnet. +func (c *Client) Send(payload []byte) error { + if c.dryRun { + fmt.Printf("[dry-run] payload received: %d bytes — would send to %s\n", + len(payload), c.journalistAddr) + return nil + } + msg := sendMsg{ + Type: "send", + Message: base64.StdEncoding.EncodeToString(payload), + Recipient: c.journalistAddr, + WithReplySurb: false, + } + raw, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if err := websocket.Message.Send(c.ws, string(raw)); err != nil { + return fmt.Errorf("ws send: %w", err) + } + return nil +} + +// Stop terminates the subprocess. +func (c *Client) Stop() { + if c.ws != nil { + c.ws.Close() + } + if c.process != nil && c.process.Process != nil { + c.process.Process.Kill() + } +} + +func extractBin(binFS fs.FS) (string, string, error) { + tmpDir, err := os.MkdirTemp("", "nymdrop-server-*") + if err != nil { + return "", "", err + } + name := "nym-client" + if runtime.GOOS == "windows" { + name += ".exe" + } + binPath := filepath.Join(tmpDir, name) + data, err := fs.ReadFile(binFS, "bin/nym-client-linux-amd64") + if err != nil { + os.RemoveAll(tmpDir) + return "", "", err + } + if err := os.WriteFile(binPath, data, 0700); err != nil { + os.RemoveAll(tmpDir) + return "", "", err + } + return binPath, tmpDir, nil +} + +// Ensure unused import io is not flagged. +var _ = io.EOF diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..9cd0366 --- /dev/null +++ b/internal/relay/relay.go @@ -0,0 +1,47 @@ +package relay + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "nymdrop/internal/nym" +) + +const maxPayloadBytes = 10 * 1024 * 1024 // 10 MB + +// Relay receives ciphertext and forwards it through Nym. Nothing is written to disk. +type Relay struct { + nym *nym.Client +} + +func New(client *nym.Client) *Relay { + return &Relay{nym: client} +} + +// Forward reads ciphertext from r, sends it over Nym, then zeros the buffer. +func (r *Relay) Forward(body io.Reader) error { + buf := make([]byte, maxPayloadBytes) + n, err := io.ReadFull(body, buf) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("read payload: %w", err) + } + payload := buf[:n] + defer zeroBytes(payload) + + // Prepend a random nonce so identical submissions look different on the wire. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return fmt.Errorf("nonce: %w", err) + } + packet := append([]byte(hex.EncodeToString(nonce)+":"), payload...) + defer zeroBytes(packet) + + return r.nym.Send(packet) +} + +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/scripts/fetch-nym-binaries.sh b/scripts/fetch-nym-binaries.sh new file mode 100755 index 0000000..ff8f7b6 --- /dev/null +++ b/scripts/fetch-nym-binaries.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fetch the nym-client binary required for the embedded build. +# The binary is not committed to keep the repository small. +# +# Target: nym-client v1.1.76 (linux/amd64), the version NymDrop is tested with. +set -euo pipefail + +VERSION="v1.1.76" +URL="https://github.com/nymtech/nym/releases/download/nym-binaries-${VERSION}/nym-client" +DEST_DIRS=( + "cmd/nymdrop/bin" + "cmd/nymdrop-reader/bin" +) + +root="$(cd "$(dirname "$0")/.." && pwd)" +tmp="$(mktemp)" +echo "Downloading nym-client ${VERSION} ..." +curl -fL "$URL" -o "$tmp" +chmod +x "$tmp" + +for d in "${DEST_DIRS[@]}"; do + mkdir -p "$root/$d" + cp "$tmp" "$root/$d/nym-client-linux-amd64" + echo " installed -> $d/nym-client-linux-amd64" +done +rm -f "$tmp" +echo "Done. You can now run: go build ./cmd/nymdrop/ && go build ./cmd/nymdrop-reader/" diff --git a/static/crypto.js b/static/crypto.js new file mode 100644 index 0000000..63b395f --- /dev/null +++ b/static/crypto.js @@ -0,0 +1,111 @@ +// NymDrop — client-side encryption via WebCrypto API. +// No external libraries. Plaintext never leaves the browser. + +// Server's X25519 public key — replaced at deploy time. +const NYMDROP_PUBKEY_HEX = "6aa99a672c48cb8eee65ddd5c5e5f8947a8d52d39a8b8eb4e11b15c87fe49038"; + +async function hexToBytes(hex) { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + return bytes; +} + +async function encryptPayload(plaintext) { + const serverPubBytes = await hexToBytes(NYMDROP_PUBKEY_HEX); + + // Generate ephemeral X25519 keypair for this submission. + const ephemeral = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "X25519" }, + true, + ["deriveKey", "deriveBits"] + ); + + // Import server's X25519 public key. + const serverKey = await crypto.subtle.importKey( + "raw", + serverPubBytes, + { name: "ECDH", namedCurve: "X25519" }, + false, + [] + ); + + // ECDH key agreement → 32-byte shared secret. + const sharedBits = await crypto.subtle.deriveBits( + { name: "ECDH", public: serverKey }, + ephemeral.privateKey, + 256 + ); + + // Derive AES-GCM-256 key via HKDF-SHA-256. + const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]); + const aesKey = await crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(32), + info: new TextEncoder().encode("nymdrop-v1") + }, + hkdfKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"] + ); + + // Encrypt plaintext. + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + aesKey, + new TextEncoder().encode(plaintext) + ); + + // Export ephemeral X25519 public key (32 bytes raw). + const ephemeralPubRaw = await crypto.subtle.exportKey("raw", ephemeral.publicKey); + + // Packet layout: ephemeral_pub(32) + iv(12) + ciphertext + const packet = new Uint8Array(32 + 12 + ciphertext.byteLength); + packet.set(new Uint8Array(ephemeralPubRaw), 0); + packet.set(iv, 32); + packet.set(new Uint8Array(ciphertext), 44); + + return packet; +} + +document.getElementById("drop-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const status = document.getElementById("status"); + status.className = ""; + status.textContent = "Encrypting..."; + + try { + let plaintext = document.getElementById("message").value; + + const fileInput = document.getElementById("file"); + if (fileInput.files.length > 0) { + const fileBytes = await fileInput.files[0].arrayBuffer(); + const fileB64 = btoa(String.fromCharCode(...new Uint8Array(fileBytes))); + plaintext += "\n---FILE:" + fileInput.files[0].name + "---\n" + fileB64; + } + + const encrypted = await encryptPayload(plaintext); + + const resp = await fetch("/submit", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: encrypted, + }); + + if (resp.ok) { + document.getElementById("drop-form").reset(); + document.getElementById("filename").textContent = ""; + status.textContent = "Delivered. No record of this submission exists on the server."; + } else { + status.className = "error"; + status.textContent = "Delivery failed. Try again."; + } + } catch (err) { + status.className = "error"; + status.textContent = "Encryption failed: " + err.message; + } +}); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..c957a01 --- /dev/null +++ b/static/index.html @@ -0,0 +1,61 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="referrer" content="no-referrer"> + <title>NymDrop — Secure Submission</title> + <link rel="stylesheet" href="/style.css"> +</head> +<body> +<div class="card"> + + <div class="logo"> + <div class="logo-icon">⬡</div> + <h1>NYMDROP</h1> + </div> + <p class="tagline">Anonymous document submission — encrypted in your browser, delivered over Nym mixnet.</p> + + <div class="badges"> + <span class="badge badge-green">END-TO-END ENCRYPTED</span> + <span class="badge badge-blue">NYM MIXNET</span> + <span class="badge badge-gray">NO LOGS</span> + <span class="badge badge-gray">NO METADATA</span> + </div> + + <form id="drop-form"> + <label for="message">Message</label> + <textarea id="message" placeholder="Write your message here. It will be encrypted before leaving your browser."></textarea> + + <div class="file-section"> + <label>Attachment (optional)</label> + <label class="file-label" for="file">📎 Choose file</label> + <input type="file" id="file"> + <span id="filename"></span> + </div> + + <button type="submit" class="submit-btn">🔒 Submit securely</button> + </form> + + <div id="status"></div> + + <hr class="divider"> + + <p class="notice"> + <span>Your submission is encrypted in this browser before being sent.</span> + The server never sees the content and retains no logs. + Once delivered over the Nym mixnet, no record of this submission exists on this server. + For maximum anonymity, submit from a public network. + </p> + +</div> +<div class="footer">NYMDROP · ZERO KNOWLEDGE · ZERO LOGS</div> +<script src="/crypto.js"></script> +<script> + document.getElementById('file').addEventListener('change', function() { + const name = this.files.length ? this.files[0].name : ''; + document.getElementById('filename').textContent = name; + }); +</script> +</body> +</html> diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..3b077d7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,179 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: #0d1117; + color: #c9d1d9; + font-family: 'Courier New', Courier, monospace; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1rem; +} + +.card { + background: #161b22; + border: 1px solid #21262d; + border-radius: 8px; + padding: 2.5rem 2rem; + width: 100%; + max-width: 580px; + box-shadow: 0 0 40px rgba(0,255,160,0.04); +} + +.logo { + display: flex; + align-items: center; + gap: 0.7rem; + margin-bottom: 0.5rem; +} + +.logo-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, #00ffa0, #0080ff); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + color: #0d1117; + font-weight: bold; +} + +h1 { + font-size: 1.4rem; + font-weight: bold; + color: #e6edf3; + letter-spacing: 0.15em; +} + +.tagline { + font-size: 0.72rem; + color: #484f58; + margin-bottom: 2rem; + letter-spacing: 0.03em; +} + +.badges { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.badge { + font-size: 0.65rem; + padding: 0.2rem 0.6rem; + border-radius: 20px; + letter-spacing: 0.05em; + font-weight: bold; +} + +.badge-green { background: rgba(0,255,160,0.08); color: #00ffa0; border: 1px solid rgba(0,255,160,0.2); } +.badge-blue { background: rgba(0,128,255,0.08); color: #58a6ff; border: 1px solid rgba(0,128,255,0.2); } +.badge-gray { background: rgba(255,255,255,0.04); color: #8b949e; border: 1px solid #30363d; } + +label { + display: block; + font-size: 0.72rem; + color: #8b949e; + margin-bottom: 0.4rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +textarea { + width: 100%; + height: 160px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #c9d1d9; + font-family: 'Courier New', Courier, monospace; + font-size: 0.88rem; + padding: 0.9rem; + resize: vertical; + margin-bottom: 1.4rem; + transition: border-color 0.2s; + outline: none; +} + +textarea:focus { border-color: #00ffa0; box-shadow: 0 0 0 2px rgba(0,255,160,0.08); } + +.file-section { margin-bottom: 1.8rem; } + +.file-label { + display: inline-block; + background: #21262d; + border: 1px solid #30363d; + border-radius: 6px; + color: #8b949e; + font-size: 0.78rem; + padding: 0.5rem 1rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} + +.file-label:hover { border-color: #58a6ff; color: #58a6ff; } + +#file { display: none; } + +#filename { + font-size: 0.72rem; + color: #58a6ff; + margin-left: 0.6rem; + vertical-align: middle; +} + +.submit-btn { + width: 100%; + background: linear-gradient(135deg, #00ffa0 0%, #0080ff 100%); + border: none; + border-radius: 6px; + color: #0d1117; + font-family: 'Courier New', Courier, monospace; + font-size: 0.9rem; + font-weight: bold; + letter-spacing: 0.1em; + padding: 0.85rem; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; + text-transform: uppercase; +} + +.submit-btn:hover { opacity: 0.9; } +.submit-btn:active { transform: scale(0.99); } + +#status { + margin-top: 1rem; + font-size: 0.78rem; + min-height: 1.2rem; + text-align: center; + color: #00ffa0; +} + +#status.error { color: #f85149; } + +.divider { + border: none; + border-top: 1px solid #21262d; + margin: 2rem 0 1.2rem; +} + +.notice { + font-size: 0.68rem; + color: #3d444d; + line-height: 1.7; +} + +.notice span { color: #484f58; } + +.footer { + margin-top: 1.5rem; + font-size: 0.62rem; + color: #21262d; + text-align: center; + letter-spacing: 0.05em; +} |
