├── .github └── workflows │ ├── codeql-analysis.yml │ └── shiftleft-analysis.yml ├── .gitignore ├── ChangeLog ├── LICENSE ├── README.md ├── auth.go ├── client.go ├── fish-shell └── functions │ ├── pkc.fish │ ├── pkf.fish │ ├── pkfr.fish │ ├── pkm.fish │ ├── pko.fish │ ├── pkp.fish │ ├── pkpr.fish │ └── pkz.fish ├── genkeys.go ├── go.mod ├── go.sum ├── piknik.go ├── piknik.png ├── server.go ├── signals_bsd.go ├── signals_notbsd.go ├── terminal_notty.go ├── terminal_tty.go ├── test.sh └── zsh.aliases /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 18 * * 6' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /.github/workflows/shiftleft-analysis.yml: -------------------------------------------------------------------------------- 1 | # This workflow integrates ShiftLeft Scan with GitHub's code scanning feature 2 | # ShiftLeft Scan is a free open-source security tool for modern DevOps teams 3 | # Visit https://slscan.io/en/latest/integrations/github-actions/ for help 4 | name: ShiftLeft Scan 5 | 6 | # This section configures the trigger for the workflow. Feel free to customize depending on your convention 7 | on: push 8 | 9 | jobs: 10 | Scan-Build: 11 | # Scan runs on ubuntu, mac and windows 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | # Instructions 16 | # 1. Setup JDK, Node.js, Python etc depending on your project type 17 | # 2. Compile or build the project before invoking scan 18 | # Example: mvn compile, or npm install or pip install goes here 19 | # 3. Invoke ShiftLeft Scan with the github token. Leave the workspace empty to use relative url 20 | 21 | - name: Perform ShiftLeft Scan 22 | uses: ShiftLeftSecurity/scan-action@master 23 | env: 24 | WORKSPACE: "" 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | output: reports 28 | # Scan auto-detects the languages in your project. To override uncomment the below variable and set the type 29 | # type: credscan,java 30 | # type: python 31 | 32 | - name: Upload report 33 | uses: github/codeql-action/upload-sarif@v1 34 | with: 35 | sarif_file: reports 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | piknik 27 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | * Version 0.9.1 2 | - Piknik can now be compiled for Plan9 and Solaris. 3 | - Sending an INFO signal to the server now prints whether the clipboard 4 | is empty or not. 5 | - Godep was replaced with Glide. 6 | - Documentation fixes. 7 | 8 | * Version 0.9 9 | - Support for protocol version 3 (Piknik <= 0.2) was removed from the server. 10 | Please upgrade the clients running these old versions. 11 | - Compatibility with protocol version 4 (Piknik >= 0.3 and <= 0.7) was 12 | restored. 13 | 14 | * Version 0.8 15 | - The clipboard now includes a timestamp, so that clients can reject 16 | content that is too old. By default, the maximum age is 7 days. This 17 | can be adjusted with a "TTL" property in the configuration file. The 18 | duration is expressed in seconds. 19 | 20 | * Version 0.7 21 | - The number of simultaneous connections to a Piknik server can now 22 | be controlled with the `-maxclients` command-line flag. When more than 23 | 90% of the available slots are filled, new sessions can only be open by 24 | client IPs having recently completed a successful handshake. 25 | - New command-line flags: `-timeout` and `-datatimeout` in order to 26 | enforce deadlines for handshakes and data transfers. 27 | 28 | * Version 0.6 29 | - Improved error messages 30 | 31 | * Version 0.5 32 | - A new switch, `-password` can now follow `-genkeys` in order to 33 | deterministically derive the keys from a password. Not recommended, 34 | but it can be useful to create an temporary initial configuration on air 35 | gapped devices. 36 | - An Homebrew (tap) formula was added to easily install it and keep it up 37 | to date on MacOS. 38 | 39 | * Version 0.4 40 | - Servers recomputed the key ID instead of reading it from the 41 | client, requiring the `EncryptSk` property to be present. This is no 42 | longer the case. Thanks to jpmens@ for the bug report. 43 | 44 | * Version 0.3 45 | - Improved documentation 46 | - The protocol was slightly changed; old clients are still supported 47 | but the server must be updated in order to communicate with clients 48 | running this version. 49 | - Precompiled binaries are now available for many architectures. 50 | 51 | * Version 0.2 52 | - New `-move` operation to paste the clipboard content and delete it 53 | afterwards. This introduces a protocol change, requiring an update to 54 | the server and to the clients. 55 | 56 | * Version 0.1 57 | - Initial public release. 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC LICENSE. 2 | 3 | Copyright (c) 2016-2024, Frank Denis 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest release](https://img.shields.io/github/release/jedisct1/piknik.svg)](https://github.com/jedisct1/piknik/releases/latest) 2 | [![Build status](https://travis-ci.com/jedisct1/piknik.svg?branch=master)](https://travis-ci.com/jedisct1/piknik?branch=master) 3 | ![CodeQL scan](https://github.com/jedisct1/piknik/workflows/Code%20scanning%20-%20action/badge.svg) 4 | 5 | # Piknik 6 | 7 | Copy/paste anything over the network! 8 | 9 | [[watch a demo on Asciinema](https://asciinema.org/a/80708)] - 10 | [[download the source code / binaries](https://github.com/jedisct1/piknik/releases/latest)] 11 | 12 | ![Piknik](https://raw.github.com/jedisct1/piknik/master/piknik.png) 13 | 14 | Ever needed a copy/paste clipboard that works over the network? 15 | 16 | Piknik seamlessly and securely transfers URLs, code snippets, documents, virtually anything between arbitrary hosts. 17 | 18 | No SSH needed, and hosts can sit behind NAT gateways, on different networks. 19 | 20 | Fill in the clipboard ("copy") with whatever comes in to the standard input: 21 | 22 | ```sh 23 | $ pkc 24 | clipboard content 25 | ``` 26 | 27 | Magically retrieve that content from any other host having Piknik installed with the same configuration: 28 | 29 | ```sh 30 | $ pkp 31 | clipboard content 32 | ``` 33 | 34 | Boom. 35 | 36 | Obviously, it can be used to transfer files as well: 37 | 38 | ```sh 39 | $ pkc < kitten.gif 40 | $ pkp > kittencopy.gif 41 | ``` 42 | 43 | ```sh 44 | $ tar cvf - *.txt | pkc 45 | $ pkp | tar xvf - 46 | ``` 47 | 48 | In order to work around firewalls/NAT gatways, the clipboard content transits over TCP via a staging server. 49 | 50 | Nothing transits without end-to-end encryption; the server cannot learn much about what the clipboard actually contains. 51 | 52 | Data can be shared between different operating systems, including MacOS, Linux and Windows. 53 | 54 | ## Installation 55 | 56 | ### Option 1: use precompiled binaries 57 | 58 | Precompiled binaries for MacOS, Linux (i386, x86_64, ARM), Win32, Win64, DragonflyBSD, NetBSD and FreeBSD can be downloaded here: 59 | https://github.com/jedisct1/piknik/releases/latest 60 | 61 | ### Option 2 (on MacOS): use Homebrew 62 | 63 | ```sh 64 | $ brew install piknik 65 | ``` 66 | 67 | ### Option 3: compile the source code 68 | 69 | This project is written in Go. 70 | 71 | Go >= 1.11 is required, as well as the following incantation: 72 | 73 | ```sh 74 | $ go build 75 | ``` 76 | 77 | The `piknik` executable file should then be available in current path. 78 | 79 | ## Setup 80 | 81 | Piknik requires a bunch of keys. Generate them all with 82 | 83 | ```sh 84 | $ piknik -genkeys 85 | ``` 86 | 87 | This generates random keys (highly recommended). 88 | 89 | You will need to copy parts (not all!) of that command's output to a `piknik.toml` configuration file. 90 | 91 | A temporary alternative is to derive the keys from a password. The same password will always generate the same set of keys, on all platforms. In order to do so, add the `-password` switch: 92 | 93 | ```sh 94 | $ piknik -genkeys -password 95 | ``` 96 | 97 | The output of the `-genkeys` command is all you need to build a configuration file. 98 | 99 | Only copy the section for servers on the staging server. Only copy the section for clients on the clients. 100 | 101 | Is a host gonna act both as a staging server and as a client? Ponder on it before copying the "hybrid" section, but it's there, just in case. 102 | 103 | The default location for the configuration file is `~/.piknik.toml`. With the exception of Windows, where dot-files are not so common. On that platform, the file is simply called `piknik.toml`. 104 | 105 | Sample configuration file for a staging server: 106 | 107 | ```toml 108 | Listen = "0.0.0.0:8075" # Edit appropriately 109 | Psk = "bf82bab384697243fbf616d3428477a563e33268f0f2307dd14e7245dd8c995d" 110 | SignPk = "0c41ca9b0a1b5fe4daae789534e72329a93a352a6ad73d6f1d368d8eff37271c" 111 | ``` 112 | 113 | Sample configuration file for clients: 114 | 115 | ```toml 116 | Connect = "127.0.0.1:8075" # Edit appropriately 117 | Psk = "bf82bab384697243fbf616d3428477a563e33268f0f2307dd14e7245dd8c995d" 118 | SignPk = "0c41ca9b0a1b5fe4daae789534e72329a93a352a6ad73d6f1d368d8eff37271c" 119 | SignSk = "cecf1d92052f7ba87da36ac3e4a745b64ade8f9e908e52b4f7cd41235dfe7481" 120 | EncryptSk = "2f530eb85e59c1977fce726df9f87345206f2a3d40bf91f9e0e9eeec2c59a3e4" 121 | ``` 122 | 123 | Do not use these, uh? Get your very own keys with the `piknik -genkeys` command. 124 | Edit the `Connect` and `Listen` properties to reflect the staging server IP and port. 125 | And `chmod 600 ~/.piknik.toml` might not be a bad idea. 126 | 127 | Don't like the default config file location? Use the `-config` switch. 128 | 129 | ## Usage (staging server) 130 | 131 | Run the following command on the staging server (or use `runit`, `openrc`, `systemd`, whatever to run it as a background service): 132 | 133 | ```sh 134 | $ piknik -server 135 | ``` 136 | 137 | The staging server has to be publicly accessible. At the very least, it must be reachable by the clients over TCP with the port you specify in the configuration. 138 | 139 | Commands without a valid API key (present in the client configuration file) will be rejected by the server. 140 | 141 | ## Usage (clients) 142 | 143 | ```sh 144 | $ piknik -copy 145 | ``` 146 | 147 | Copy the standard input to the clipboard. 148 | 149 | ```sh 150 | $ piknik -paste 151 | ``` 152 | 153 | Retrieve the content of the clipboard and spit it to the standard output. 154 | `-paste` is actually a no-op. This is the default action if `-copy` was not specified. 155 | 156 | ```sh 157 | $ piknik -move 158 | ``` 159 | 160 | Retrieve the content of the clipboard, spit it to the standard output 161 | and clear the clipboard. Not necessarily in this order. 162 | Only one lucky client will have the privilege to see the content. 163 | 164 | That's it. 165 | 166 | Feed it anything. Text, binary data, whatever. As long as it fits in memory. 167 | 168 | ## Suggested shell aliases 169 | 170 | Wait. Where are the `pkc` and `pkp` commands mentioned earlier? 171 | 172 | Sample shell aliases: 173 | 174 | ```sh 175 | # pko : copy to the clipboard 176 | pko() { 177 | echo "$*" | piknik -copy 178 | } 179 | 180 | # pkf : copy the content of to the clipboard 181 | pkf() { 182 | piknik -copy < $1 183 | } 184 | 185 | # pkc : read the content to copy to the clipboard from STDIN 186 | alias pkc='piknik -copy' 187 | 188 | # pkp : paste the clipboard content 189 | alias pkp='piknik -paste' 190 | 191 | # pkm : move the clipboard content 192 | alias pkm='piknik -move' 193 | 194 | # pkz : delete the clipboard content 195 | alias pkz='piknik -copy < /dev/null' 196 | 197 | # pkfr [] : send a whole directory to the clipboard, as a tar archive 198 | pkfr() { 199 | tar czpvf - ${1:-.} | piknik -copy 200 | } 201 | 202 | # pkpr : extract clipboard content sent using the pkfr command 203 | alias pkpr='piknik -paste | tar xzpvf -' 204 | ``` 205 | 206 | ## Piknik integration in third-party packages 207 | 208 | * The [Piknik package for Atom](https://atom.io/packages/piknik) 209 | allows copying/pasting text between hosts running the Atom text editor. 210 | * The [Piknik package for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=jedisct1.piknik) 211 | allows copying/pasting text between hosts running the Visual Studio Code text editor. 212 | 213 | ## Use cases 214 | 215 | Use it to: 216 | 217 | * Securely send passwords, API keys, URLs from one host to another 218 | * Share a clipboard with your teammates (which can be a lot of fun) 219 | * Copy data from/to isolated VMs, without the VMWare tools or shared volumes (great for unsupported operating systems and malware sandboxes) 220 | * Copy files from/to a Windows machine, without Samba or SSH 221 | * Transfer data between hosts sitting behind firewalls/NAT gateways 222 | * Easily copy configuration files to multiple hosts 223 | * Start a slow download at the office, retrieve it later at home 224 | * Quickly backup a file to the cloud before messing with it 225 | * ...and more! 226 | 227 | ## Protocol 228 | 229 | Common definitions: 230 | 231 | ```text 232 | k: API key 233 | ek: 256-bit symmetric encryption key 234 | ekid: encryption key id encoded as a 64-bit little endian integer 235 | m: plaintext 236 | ct: XChaCha20 ek,n (m) 237 | Hk,s: BLAKE2b(domain="SK", key=k, salt=s, size=32) 238 | Len(x): x encoded as a 64-bit little endian unsigned integer 239 | n: random 192-bit nonce 240 | r: random 256-bit client nonce 241 | r': random 256-bit server nonce 242 | ts: Unix timestamp as a 64-bit little endian integer 243 | Sig: Ed25519 244 | v: 6 245 | ``` 246 | 247 | Copy: 248 | 249 | ```text 250 | -> v || r || h0 251 | h0 := Hk,0(v || r) 252 | 253 | <- v || r' || h1 254 | h1 := Hk,1(v || r' || h0) 255 | 256 | -> 'S' || h2 || Len(ekid || n || ct) || ts || s || ekid || n || ct 257 | s := Sig(ekid || n || ct) 258 | h2 := Hk,2(h1 || 'S' || ts || s) 259 | 260 | <- Hk,3(h2) 261 | ``` 262 | 263 | Move/Paste: 264 | 265 | ```text 266 | Move: opcode := 'M' 267 | Paste: opcode := 'G' 268 | 269 | -> v || r || h0 270 | h0 := Hk,0(v || r) 271 | 272 | <- v || r' || h1 273 | h1 := Hk,1(v || r' || H0) 274 | 275 | -> opcode || h2 276 | h2 := Hk,2(h1 || opcode) 277 | 278 | <- Hk,3(h2 || ts || s) || Len(ekid || n || ct) || ts || s || ekid || n || ct 279 | s := Sig(ekid || n || ct) 280 | ``` 281 | 282 | ## License 283 | 284 | [ISC](https://en.wikipedia.org/wiki/ISC_license). 285 | 286 | ## Credits 287 | 288 | Piknik diagram by [EasyPi](https://easypi.herokuapp.com/copy-paste-anything-over-network/). 289 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import blake2b "github.com/minio/blake2b-simd" 4 | 5 | func auth0(conf Conf, clientVersion byte, r []byte) []byte { 6 | hf0, _ := blake2b.New(&blake2b.Config{ 7 | Key: conf.Psk, 8 | Person: []byte(DomainStr), 9 | Size: 32, 10 | Salt: []byte{0}, 11 | }) 12 | hf0.Write([]byte{clientVersion}) 13 | hf0.Write(r) 14 | h0 := hf0.Sum(nil) 15 | 16 | return h0 17 | } 18 | 19 | func auth1(conf Conf, clientVersion byte, h0 []byte, r2 []byte) []byte { 20 | hf1, _ := blake2b.New(&blake2b.Config{ 21 | Key: conf.Psk, 22 | Person: []byte(DomainStr), 23 | Size: 32, 24 | Salt: []byte{1}, 25 | }) 26 | hf1.Write([]byte{clientVersion}) 27 | hf1.Write(r2) 28 | hf1.Write(h0) 29 | h1 := hf1.Sum(nil) 30 | 31 | return h1 32 | } 33 | 34 | func auth2get(conf Conf, clientVersion byte, h1 []byte, opcode byte) []byte { 35 | hf2, _ := blake2b.New(&blake2b.Config{ 36 | Key: conf.Psk, 37 | Person: []byte(DomainStr), 38 | Size: 32, 39 | Salt: []byte{2}, 40 | }) 41 | hf2.Write(h1) 42 | hf2.Write([]byte{opcode}) 43 | h2 := hf2.Sum(nil) 44 | 45 | return h2 46 | } 47 | 48 | func auth2store(conf Conf, clientVersion byte, h1 []byte, opcode byte, 49 | ts []byte, signature []byte, 50 | ) []byte { 51 | hf2, _ := blake2b.New(&blake2b.Config{ 52 | Key: conf.Psk, 53 | Person: []byte(DomainStr), 54 | Size: 32, 55 | Salt: []byte{2}, 56 | }) 57 | hf2.Write(h1) 58 | hf2.Write([]byte{opcode}) 59 | hf2.Write(ts) 60 | hf2.Write(signature) 61 | h2 := hf2.Sum(nil) 62 | 63 | return h2 64 | } 65 | 66 | func auth3get(conf Conf, clientVersion byte, h2 []byte, 67 | ts []byte, signature []byte, 68 | ) []byte { 69 | hf3, _ := blake2b.New(&blake2b.Config{ 70 | Key: conf.Psk, 71 | Person: []byte(DomainStr), 72 | Size: 32, 73 | Salt: []byte{3}, 74 | }) 75 | hf3.Write(h2) 76 | hf3.Write(ts) 77 | hf3.Write(signature) 78 | h3 := hf3.Sum(nil) 79 | 80 | return h3 81 | } 82 | 83 | func auth3store(conf Conf, h2 []byte) []byte { 84 | hf3, _ := blake2b.New(&blake2b.Config{ 85 | Key: conf.Psk, 86 | Person: []byte(DomainStr), 87 | Size: 32, 88 | Salt: []byte{3}, 89 | }) 90 | hf3.Write(h2) 91 | h3 := hf3.Sum(nil) 92 | 93 | return h3 94 | } 95 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "crypto/rand" 7 | "crypto/subtle" 8 | "encoding/binary" 9 | "fmt" 10 | "io" 11 | "log" 12 | "net" 13 | "os" 14 | "syscall" 15 | "time" 16 | 17 | "golang.org/x/crypto/chacha20" 18 | "golang.org/x/crypto/ed25519" 19 | ) 20 | 21 | // DefaultClientVersion - Default client version 22 | const DefaultClientVersion = byte(6) 23 | 24 | // Client - Client data 25 | type Client struct { 26 | conf Conf 27 | conn net.Conn 28 | reader *bufio.Reader 29 | writer *bufio.Writer 30 | version byte 31 | } 32 | 33 | func (client *Client) copyOperation(h1 []byte) { 34 | ts := make([]byte, 8) 35 | binary.LittleEndian.PutUint64(ts, uint64(time.Now().Unix())) 36 | 37 | conf, reader, writer := client.conf, client.reader, client.writer 38 | 39 | var contentWithEncryptSkIDAndNonceBuf bytes.Buffer 40 | contentWithEncryptSkIDAndNonceBuf.Grow(8 + 24 + bytes.MinRead) 41 | 42 | contentWithEncryptSkIDAndNonceBuf.Write(conf.EncryptSkID) 43 | 44 | nonce := make([]byte, 24) 45 | if _, err := rand.Read(nonce); err != nil { 46 | log.Fatal(err) 47 | } 48 | contentWithEncryptSkIDAndNonceBuf.Write(nonce) 49 | 50 | _, err := contentWithEncryptSkIDAndNonceBuf.ReadFrom(os.Stdin) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | contentWithEncryptSkIDAndNonce := contentWithEncryptSkIDAndNonceBuf.Bytes() 55 | 56 | cipher, err := chacha20.NewUnauthenticatedCipher(conf.EncryptSk, nonce) 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | opcode := byte('S') 61 | cipher.XORKeyStream(contentWithEncryptSkIDAndNonce[8+24:], contentWithEncryptSkIDAndNonce[8+24:]) 62 | signature := ed25519.Sign(conf.SignSk, contentWithEncryptSkIDAndNonce) 63 | 64 | client.conn.SetDeadline(time.Now().Add(conf.DataTimeout)) 65 | h2 := auth2store(conf, client.version, h1, opcode, ts, signature) 66 | writer.WriteByte(opcode) 67 | writer.Write(h2) 68 | ciphertextWithEncryptSkIDAndNonceLen := uint64(len(contentWithEncryptSkIDAndNonce)) 69 | binary.Write(writer, binary.LittleEndian, ciphertextWithEncryptSkIDAndNonceLen) 70 | writer.Write(ts) 71 | writer.Write(signature) 72 | writer.Write(contentWithEncryptSkIDAndNonce) 73 | if err = writer.Flush(); err != nil { 74 | log.Fatal(err) 75 | } 76 | rbuf := make([]byte, 32) 77 | if _, err = io.ReadFull(reader, rbuf); err != nil { 78 | if err == io.ErrUnexpectedEOF { 79 | log.Fatal("The server may be running an incompatible version") 80 | } else { 81 | log.Fatal(err) 82 | } 83 | } 84 | h3 := rbuf 85 | wh3 := auth3store(conf, h2) 86 | if subtle.ConstantTimeCompare(wh3, h3) != 1 { 87 | log.Fatal("Incorrect authentication code") 88 | } 89 | if IsTerminal(int(syscall.Stderr)) { 90 | os.Stderr.WriteString("Sent\n") 91 | } 92 | } 93 | 94 | func (client *Client) pasteOperation(h1 []byte, isMove bool) { 95 | conf, reader, writer := client.conf, client.reader, client.writer 96 | opcode := byte('G') 97 | if isMove { 98 | opcode = byte('M') 99 | } 100 | h2 := auth2get(conf, client.version, h1, opcode) 101 | writer.WriteByte(opcode) 102 | writer.Write(h2) 103 | if err := writer.Flush(); err != nil { 104 | log.Fatal(err) 105 | } 106 | rbuf := make([]byte, 112) 107 | if nbread, err := io.ReadFull(reader, rbuf); err != nil { 108 | if err == io.ErrUnexpectedEOF { 109 | if nbread < 80 { 110 | log.Fatal("The clipboard might be empty") 111 | } else { 112 | log.Fatal("The server may be running an incompatible version") 113 | } 114 | } else { 115 | log.Fatal(err) 116 | } 117 | } 118 | h3 := rbuf[0:32] 119 | ciphertextWithEncryptSkIDAndNonceLen := binary.LittleEndian.Uint64(rbuf[32:40]) 120 | ts := rbuf[40:48] 121 | signature := rbuf[48:112] 122 | wh3 := auth3get(conf, client.version, h2, ts, signature) 123 | if subtle.ConstantTimeCompare(wh3, h3) != 1 { 124 | log.Fatal("Incorrect authentication code") 125 | } 126 | elapsed := time.Since(time.Unix(int64(binary.LittleEndian.Uint64(ts)), 0)) 127 | if elapsed >= conf.TTL { 128 | log.Fatal("Clipboard content is too old") 129 | } 130 | if ciphertextWithEncryptSkIDAndNonceLen < 8+24 { 131 | log.Fatal("Clipboard content is too short") 132 | } 133 | ciphertextWithEncryptSkIDAndNonce := make([]byte, ciphertextWithEncryptSkIDAndNonceLen) 134 | client.conn.SetDeadline(time.Now().Add(conf.DataTimeout)) 135 | if _, err := io.ReadFull(reader, ciphertextWithEncryptSkIDAndNonce); err != nil { 136 | if err == io.ErrUnexpectedEOF { 137 | log.Fatal("The server may be running an incompatible version") 138 | } else { 139 | log.Fatal(err) 140 | } 141 | } 142 | encryptSkID := ciphertextWithEncryptSkIDAndNonce[0:8] 143 | if !bytes.Equal(conf.EncryptSkID, encryptSkID) { 144 | wEncryptSkIDStr := binary.LittleEndian.Uint64(conf.EncryptSkID) 145 | encryptSkIDStr := binary.LittleEndian.Uint64(encryptSkID) 146 | log.Fatal(fmt.Sprintf("Configured key ID is %v but content was encrypted using key ID %v", 147 | wEncryptSkIDStr, encryptSkIDStr)) 148 | } 149 | if !ed25519.Verify(conf.SignPk, ciphertextWithEncryptSkIDAndNonce, signature) { 150 | log.Fatal("Signature doesn't verify") 151 | } 152 | nonce := ciphertextWithEncryptSkIDAndNonce[8:32] 153 | cipher, err := chacha20.NewUnauthenticatedCipher(conf.EncryptSk, nonce) 154 | if err != nil { 155 | log.Fatal(err) 156 | } 157 | content := ciphertextWithEncryptSkIDAndNonce[32:] 158 | cipher.XORKeyStream(content, content) 159 | binary.Write(os.Stdout, binary.LittleEndian, content) 160 | } 161 | 162 | // RunClient - Process a client query 163 | func RunClient(conf Conf, isCopy bool, isMove bool) { 164 | conn, err := net.DialTimeout("tcp", conf.Connect, conf.Timeout) 165 | if err != nil { 166 | log.Fatal(fmt.Sprintf("Unable to connect to %v - Is a Piknik server running on that host?", 167 | conf.Connect)) 168 | } 169 | defer conn.Close() 170 | 171 | conn.SetDeadline(time.Now().Add(conf.Timeout)) 172 | reader, writer := bufio.NewReader(conn), bufio.NewWriter(conn) 173 | client := Client{ 174 | conf: conf, 175 | conn: conn, 176 | reader: reader, 177 | writer: writer, 178 | version: DefaultClientVersion, 179 | } 180 | r := make([]byte, 32) 181 | if _, err = rand.Read(r); err != nil { 182 | log.Fatal(err) 183 | } 184 | h0 := auth0(conf, client.version, r) 185 | writer.Write([]byte{client.version}) 186 | writer.Write(r) 187 | writer.Write(h0) 188 | if err := writer.Flush(); err != nil { 189 | log.Fatal(err) 190 | } 191 | rbuf := make([]byte, 65) 192 | if nbread, err := io.ReadFull(reader, rbuf); err != nil { 193 | if nbread < 2 { 194 | log.Fatal("The server rejected the connection - Check that it is running the same Piknik version or retry later") 195 | } else { 196 | log.Fatal("The server doesn't support this protocol") 197 | } 198 | } 199 | if serverVersion := rbuf[0]; serverVersion != client.version { 200 | log.Fatal(fmt.Sprintf("Incompatible server version (client version: %v - server version: %v)", 201 | client.version, serverVersion)) 202 | } 203 | r2 := rbuf[1:33] 204 | h1 := rbuf[33:65] 205 | wh1 := auth1(conf, client.version, h0, r2) 206 | if subtle.ConstantTimeCompare(wh1, h1) != 1 { 207 | log.Fatal("Incorrect authentication code") 208 | } 209 | if isCopy { 210 | client.copyOperation(h1) 211 | } else { 212 | client.pasteOperation(h1, isMove) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /fish-shell/functions/pkc.fish: -------------------------------------------------------------------------------- 1 | function pkc --description 'read the content to copy to the piknik clipboard from STDIN' 2 | piknik -copy; 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkf.fish: -------------------------------------------------------------------------------- 1 | function pkf --description 'copy the content of a file to the piknik clipboard' 2 | piknik -copy < $argv[1]; 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkfr.fish: -------------------------------------------------------------------------------- 1 | function pkfr --description 'send a whole directory to the piknik clipboard, as a tar archive' 2 | tar czpvf - $argv | piknik -copy 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkm.fish: -------------------------------------------------------------------------------- 1 | function pkm --description 'move the piknik clipboard content' 2 | piknik -move; 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pko.fish: -------------------------------------------------------------------------------- 1 | function pko --description 'copy inline content to the piknik clipboard' 2 | echo $argv | piknik -copy; 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkp.fish: -------------------------------------------------------------------------------- 1 | function pkp --description 'paste the piknik clipboard content' 2 | piknik -paste; 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkpr.fish: -------------------------------------------------------------------------------- 1 | function pkpr --description 'extract piknik clipboard content sent using the pkfr command' 2 | piknik -paste | tar xzpvf - 3 | end 4 | -------------------------------------------------------------------------------- /fish-shell/functions/pkz.fish: -------------------------------------------------------------------------------- 1 | function pkz --description 'delete the piknik clipboard content' 2 | piknik -copy < /dev/null; 3 | end 4 | -------------------------------------------------------------------------------- /genkeys.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "strings" 12 | 13 | "golang.org/x/crypto/ed25519" 14 | "golang.org/x/crypto/scrypt" 15 | ) 16 | 17 | // DeterministicRand - Deterministic random function 18 | type DeterministicRand struct { 19 | pool []byte 20 | pos int 21 | } 22 | 23 | var deterministicRand DeterministicRand 24 | 25 | func initDeterministicRand(leKey []byte, poolLen int) { 26 | key, err := scrypt.Key(leKey, []byte{}, 16384, 12, 1, poolLen) 27 | if err != nil { 28 | log.Panic(err) 29 | } 30 | deterministicRand.pool, deterministicRand.pos = key, 0 31 | } 32 | 33 | func (DeterministicRand) Read(p []byte) (n int, err error) { 34 | reqLen := len(p) 35 | left := len(deterministicRand.pool) - deterministicRand.pos 36 | if left < reqLen { 37 | log.Panic(fmt.Sprintf("rand pool exhaustion (%v left, %v needed)", 38 | left, reqLen)) 39 | } 40 | copy(p, deterministicRand.pool[deterministicRand.pos:deterministicRand.pos+reqLen]) 41 | for i := 0; i < reqLen; i++ { 42 | deterministicRand.pool[i] = 0 43 | } 44 | deterministicRand.pos += reqLen 45 | 46 | return reqLen, nil 47 | } 48 | 49 | func genKeys(conf Conf, configFile string, leKey string) { 50 | randRead, randReader := rand.Read, io.Reader(nil) 51 | if len(leKey) > 0 { 52 | initDeterministicRand([]byte(leKey), 96) 53 | randRead, randReader = deterministicRand.Read, deterministicRand 54 | } 55 | psk := make([]byte, 32) 56 | if _, err := randRead(psk); err != nil { 57 | log.Fatal(err) 58 | } 59 | pskHex := hex.EncodeToString(psk) 60 | 61 | encryptSk := make([]byte, 32) 62 | if _, err := randRead(encryptSk); err != nil { 63 | log.Fatal(err) 64 | } 65 | encryptSkHex := hex.EncodeToString(encryptSk) 66 | 67 | signPk, signSk, err := ed25519.GenerateKey(randReader) 68 | if err != nil { 69 | log.Fatal(err) 70 | } 71 | signPkHex := hex.EncodeToString(signPk) 72 | signSkHex := hex.EncodeToString(signSk[0:32]) 73 | 74 | fmt.Printf("\n\n--- Create a file named %s with only the lines relevant to your configuration ---\n\n\n", configFile) 75 | fmt.Printf("# Configuration for a client\n\n") 76 | fmt.Printf("Connect = %q\t# Edit appropriately\n", conf.Connect) 77 | fmt.Printf("Psk = %q\n", pskHex) 78 | fmt.Printf("SignPk = %q\n", signPkHex) 79 | fmt.Printf("SignSk = %q\n", signSkHex) 80 | fmt.Printf("EncryptSk = %q\n", encryptSkHex) 81 | 82 | fmt.Printf("\n\n") 83 | 84 | fmt.Printf("# Configuration for a server\n\n") 85 | fmt.Printf("Listen = %q\t# Edit appropriately\n", conf.Listen) 86 | fmt.Printf("Psk = %q\n", pskHex) 87 | fmt.Printf("SignPk = %q\n", signPkHex) 88 | 89 | fmt.Printf("\n\n") 90 | 91 | fmt.Printf("# Hybrid configuration\n\n") 92 | fmt.Printf("Connect = %q\t# Edit appropriately\n", conf.Connect) 93 | fmt.Printf("Listen = %q\t# Edit appropriately\n", conf.Listen) 94 | fmt.Printf("Psk = %q\n", pskHex) 95 | fmt.Printf("SignPk = %q\n", signPkHex) 96 | fmt.Printf("SignSk = %q\n", signSkHex) 97 | fmt.Printf("EncryptSk = %q\n", encryptSkHex) 98 | } 99 | 100 | func getPassword(prompt string) string { 101 | os.Stdout.Write([]byte(prompt)) 102 | reader := bufio.NewReader(os.Stdin) 103 | password, err := reader.ReadString('\n') 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | return strings.TrimSpace(password) 108 | } 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jedisct1/piknik 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.4.0 7 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 8 | github.com/mitchellh/go-homedir v1.1.0 9 | golang.org/x/crypto v0.31.0 10 | golang.org/x/term v0.27.0 11 | ) 12 | 13 | require golang.org/x/sys v0.28.0 // indirect 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g= 4 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= 5 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 6 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 7 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 8 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 9 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 10 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 11 | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= 12 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 13 | -------------------------------------------------------------------------------- /piknik.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/hex" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "runtime" 11 | "time" 12 | 13 | "github.com/BurntSushi/toml" 14 | "github.com/minio/blake2b-simd" 15 | "github.com/mitchellh/go-homedir" 16 | ) 17 | 18 | const ( 19 | // Version - Piknik version 20 | Version = "0.10.2" 21 | // DomainStr - BLAKE2 domain (personalization) 22 | DomainStr = "PK" 23 | // DefaultListen - Default value for the Listen parameter 24 | DefaultListen = "0.0.0.0:8075" 25 | // DefaultConnect - Default value for the Connect parameter 26 | DefaultConnect = "127.0.0.1:8075" 27 | // DefaultTTL - Time after the clipboard is considered obsolete, in seconds 28 | DefaultTTL = 7 * 24 * time.Hour 29 | ) 30 | 31 | type tomlConfig struct { 32 | Connect string 33 | Listen string 34 | EncryptSk string 35 | EncryptSkID uint64 36 | Psk string 37 | SignPk string 38 | SignSk string 39 | Timeout uint 40 | DataTimeout uint 41 | TTL uint 42 | } 43 | 44 | // Conf - Shared config 45 | type Conf struct { 46 | Connect string 47 | Listen string 48 | MaxClients uint64 49 | MaxLen uint64 50 | EncryptSk []byte 51 | EncryptSkID []byte 52 | Psk []byte 53 | SignPk []byte 54 | SignSk []byte 55 | Timeout time.Duration 56 | DataTimeout time.Duration 57 | TTL time.Duration 58 | TrustedIPCount uint64 59 | } 60 | 61 | func expandConfigFile(path string) string { 62 | file, err := homedir.Expand(path) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | return file 67 | } 68 | 69 | func version() { 70 | fmt.Printf("\nPiknik v%v (protocol version: %v)\n", 71 | Version, DefaultClientVersion) 72 | } 73 | 74 | func confCheck(conf Conf, isServer bool) { 75 | if len(conf.Psk) != 32 { 76 | log.Fatal("Configuration error: the Psk property is either missing or invalid") 77 | } 78 | if len(conf.SignPk) != 32 { 79 | log.Fatal("Configuration error: the SignPk property is either missing or invalid") 80 | } 81 | if isServer { 82 | if len(conf.Listen) < 3 { 83 | log.Fatal("Configuration error: the Listen property must be valid for a server") 84 | } 85 | if conf.MaxClients <= 0 { 86 | log.Fatal("Configuration error: MaxClients should be at least 1") 87 | } 88 | } else { 89 | if len(conf.Connect) < 3 { 90 | log.Fatal("Configuration error: the Connect property must be valid for a client") 91 | } 92 | if len(conf.EncryptSk) != 32 || len(conf.SignSk) != 64 { 93 | log.Fatal("Configuration error: the EncryptSk and SignSk properties must be present\n" + 94 | "and valid in order to use this command in client mode") 95 | } 96 | if conf.TTL <= 0 { 97 | log.Fatal("TTL cannot be 0") 98 | } 99 | } 100 | } 101 | 102 | func main() { 103 | log.SetFlags(0) 104 | 105 | isCopy := flag.Bool("copy", false, "store content (copy)") 106 | _ = flag.Bool("paste", false, "retrieve the content (paste) - this is the default action") 107 | isMove := flag.Bool("move", false, "retrieve and delete the clipboard content") 108 | isServer := flag.Bool("server", false, "start a server") 109 | isGenKeys := flag.Bool("genkeys", false, "generate keys") 110 | isDeterministic := flag.Bool("password", false, "derive the keys from a password (default=random keys)") 111 | maxClients := flag.Uint64("maxclients", 10, "maximum number of simultaneous client connections") 112 | maxLenMb := flag.Uint64("maxlen", 0, "maximum content length to accept in Mb (0=unlimited)") 113 | timeout := flag.Uint("timeout", 10, "connection timeout (seconds)") 114 | dataTimeout := flag.Uint("datatimeout", 3600, "data transmission timeout (seconds)") 115 | isVersion := flag.Bool("version", false, "display package version") 116 | 117 | defaultConfigFile := "~/.piknik.toml" 118 | if runtime.GOOS == "windows" { 119 | defaultConfigFile = "~/piknik.toml" 120 | } 121 | configFile := flag.String("config", defaultConfigFile, "configuration file") 122 | flag.Parse() 123 | if *isVersion { 124 | version() 125 | return 126 | } 127 | tomlData, err := os.ReadFile(expandConfigFile(*configFile)) 128 | if err != nil && !*isGenKeys { 129 | log.Fatal(err) 130 | } 131 | var tomlConf tomlConfig 132 | if _, err = toml.Decode(string(tomlData), &tomlConf); err != nil { 133 | log.Fatal(err) 134 | } 135 | var conf Conf 136 | if tomlConf.Listen == "" { 137 | conf.Listen = DefaultListen 138 | } else { 139 | conf.Listen = tomlConf.Listen 140 | } 141 | if tomlConf.Connect == "" { 142 | conf.Connect = DefaultConnect 143 | } else { 144 | conf.Connect = tomlConf.Connect 145 | } 146 | if *isGenKeys { 147 | leKey := "" 148 | if *isDeterministic { 149 | leKey = getPassword("Password> ") 150 | } 151 | genKeys(conf, *configFile, leKey) 152 | return 153 | } 154 | pskHex := tomlConf.Psk 155 | psk, err := hex.DecodeString(pskHex) 156 | if err != nil { 157 | log.Fatal(err) 158 | } 159 | conf.Psk = psk 160 | if encryptSkHex := tomlConf.EncryptSk; encryptSkHex != "" { 161 | encryptSk, err := hex.DecodeString(encryptSkHex) 162 | if err != nil { 163 | log.Fatal(err) 164 | } 165 | conf.EncryptSk = encryptSk 166 | } 167 | if signPkHex := tomlConf.SignPk; signPkHex != "" { 168 | signPk, err := hex.DecodeString(signPkHex) 169 | if err != nil { 170 | log.Fatal(err) 171 | } 172 | conf.SignPk = signPk 173 | } 174 | if encryptSkID := tomlConf.EncryptSkID; encryptSkID > 0 { 175 | conf.EncryptSkID = make([]byte, 8) 176 | binary.LittleEndian.PutUint64(conf.EncryptSkID, encryptSkID) 177 | } else if len(conf.EncryptSk) > 0 { 178 | hf, _ := blake2b.New(&blake2b.Config{ 179 | Person: []byte(DomainStr), 180 | Size: 8, 181 | }) 182 | hf.Write(conf.EncryptSk) 183 | encryptSkID := hf.Sum(nil) 184 | encryptSkID[7] &= 0x7f 185 | conf.EncryptSkID = encryptSkID 186 | } 187 | conf.TTL = DefaultTTL 188 | if ttl := tomlConf.TTL; ttl > 0 { 189 | conf.TTL = time.Duration(ttl) * time.Second 190 | } 191 | if signSkHex := tomlConf.SignSk; signSkHex != "" { 192 | signSk, err := hex.DecodeString(signSkHex) 193 | if err != nil { 194 | log.Fatal(err) 195 | } 196 | switch len(signSk) { 197 | case 32: 198 | if len(conf.SignPk) != 32 { 199 | log.Fatal("Public signing key required") 200 | } 201 | signSk = append(signSk, conf.SignPk...) 202 | case 64: 203 | default: 204 | log.Fatal("Unsupported length for the secret signing key") 205 | } 206 | conf.SignSk = signSk 207 | } 208 | conf.MaxClients = *maxClients 209 | conf.MaxLen = *maxLenMb * 1024 * 1024 210 | conf.Timeout = time.Duration(*timeout) * time.Second 211 | conf.DataTimeout = time.Duration(*dataTimeout) * time.Second 212 | conf.TrustedIPCount = uint64(float64(conf.MaxClients) * 0.1) 213 | if conf.TrustedIPCount < 1 { 214 | conf.TrustedIPCount = 1 215 | } 216 | confCheck(conf, *isServer) 217 | if *isServer { 218 | RunServer(conf) 219 | } else { 220 | RunClient(conf, *isCopy, *isMove) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /piknik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jedisct1/piknik/10f6dec9bb45dabc74a766b4f12f2c15d279b319/piknik.png -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/rand" 6 | "crypto/subtle" 7 | "encoding/binary" 8 | "io" 9 | "log" 10 | "net" 11 | "sync" 12 | "sync/atomic" 13 | "time" 14 | 15 | "golang.org/x/crypto/ed25519" 16 | ) 17 | 18 | // ClientConnection - A client connection 19 | type ClientConnection struct { 20 | conf Conf 21 | conn net.Conn 22 | reader *bufio.Reader 23 | writer *bufio.Writer 24 | clientVersion byte 25 | } 26 | 27 | // StoredContent - Paste buffer 28 | type StoredContent struct { 29 | sync.RWMutex 30 | 31 | ts []byte 32 | signature []byte 33 | ciphertextWithEncryptSkIDAndNonce []byte 34 | } 35 | 36 | var ( 37 | storedContent StoredContent 38 | trustedClients TrustedClients 39 | clientsCount = uint64(0) 40 | ) 41 | 42 | func (cnx *ClientConnection) getOperation(h1 []byte, isMove bool) { 43 | conf, reader, writer := cnx.conf, cnx.reader, cnx.writer 44 | rbuf := make([]byte, 32) 45 | if _, err := io.ReadFull(reader, rbuf); err != nil { 46 | log.Print(err) 47 | return 48 | } 49 | h2 := rbuf 50 | opcode := byte('G') 51 | if isMove { 52 | opcode = byte('M') 53 | } 54 | wh2 := auth2get(conf, cnx.clientVersion, h1, opcode) 55 | if subtle.ConstantTimeCompare(wh2, h2) != 1 { 56 | return 57 | } 58 | 59 | var ts, signature, ciphertextWithEncryptSkIDAndNonce []byte 60 | if isMove { 61 | storedContent.Lock() 62 | ts, signature, ciphertextWithEncryptSkIDAndNonce = storedContent.ts, storedContent.signature, storedContent.ciphertextWithEncryptSkIDAndNonce 63 | storedContent.ts, storedContent.signature, 64 | storedContent.ciphertextWithEncryptSkIDAndNonce = nil, nil, nil 65 | storedContent.Unlock() 66 | } else { 67 | storedContent.RLock() 68 | ts, signature, ciphertextWithEncryptSkIDAndNonce = storedContent.ts, storedContent.signature, 69 | storedContent.ciphertextWithEncryptSkIDAndNonce 70 | storedContent.RUnlock() 71 | } 72 | 73 | cnx.conn.SetDeadline(time.Now().Add(conf.DataTimeout)) 74 | h3 := auth3get(conf, cnx.clientVersion, h2, ts, signature) 75 | writer.Write(h3) 76 | ciphertextWithEncryptSkIDAndNonceLen := uint64(len(ciphertextWithEncryptSkIDAndNonce)) 77 | binary.Write(writer, binary.LittleEndian, ciphertextWithEncryptSkIDAndNonceLen) 78 | writer.Write(ts) 79 | writer.Write(signature) 80 | writer.Write(ciphertextWithEncryptSkIDAndNonce) 81 | if err := writer.Flush(); err != nil { 82 | log.Print(err) 83 | return 84 | } 85 | } 86 | 87 | func (cnx *ClientConnection) storeOperation(h1 []byte) { 88 | conf, reader, writer := cnx.conf, cnx.reader, cnx.writer 89 | rbuf := make([]byte, 112) 90 | if _, err := io.ReadFull(reader, rbuf); err != nil { 91 | log.Print(err) 92 | return 93 | } 94 | h2 := rbuf[0:32] 95 | ciphertextWithEncryptSkIDAndNonceLen := binary.LittleEndian.Uint64(rbuf[32:40]) 96 | if ciphertextWithEncryptSkIDAndNonceLen < 8+24 { 97 | log.Printf("Short encrypted message (only %v bytes)\n", ciphertextWithEncryptSkIDAndNonceLen) 98 | return 99 | } 100 | if conf.MaxLen > 0 && ciphertextWithEncryptSkIDAndNonceLen > conf.MaxLen { 101 | log.Printf("%v bytes requested to be stored, but limit set to %v bytes (%v Mb)\n", 102 | ciphertextWithEncryptSkIDAndNonceLen, conf.MaxLen, conf.MaxLen/(1024*1024)) 103 | return 104 | } 105 | var ts, signature []byte 106 | ts = rbuf[40:48] 107 | signature = rbuf[48:112] 108 | opcode := byte('S') 109 | 110 | wh2 := auth2store(conf, cnx.clientVersion, h1, opcode, ts, signature) 111 | if subtle.ConstantTimeCompare(wh2, h2) != 1 { 112 | return 113 | } 114 | ciphertextWithEncryptSkIDAndNonce := make([]byte, ciphertextWithEncryptSkIDAndNonceLen) 115 | 116 | cnx.conn.SetDeadline(time.Now().Add(conf.DataTimeout)) 117 | if _, err := io.ReadFull(reader, ciphertextWithEncryptSkIDAndNonce); err != nil { 118 | log.Print(err) 119 | return 120 | } 121 | if !ed25519.Verify(conf.SignPk, ciphertextWithEncryptSkIDAndNonce, signature) { 122 | return 123 | } 124 | h3 := auth3store(conf, h2) 125 | 126 | storedContent.Lock() 127 | storedContent.ts = ts 128 | storedContent.signature = signature 129 | storedContent.ciphertextWithEncryptSkIDAndNonce = ciphertextWithEncryptSkIDAndNonce 130 | storedContent.Unlock() 131 | 132 | writer.Write(h3) 133 | if err := writer.Flush(); err != nil { 134 | log.Print(err) 135 | return 136 | } 137 | } 138 | 139 | func handleClientConnection(conf Conf, conn net.Conn) { 140 | defer conn.Close() 141 | reader, writer := bufio.NewReader(conn), bufio.NewWriter(conn) 142 | cnx := ClientConnection{ 143 | conf: conf, 144 | conn: conn, 145 | reader: reader, 146 | writer: writer, 147 | } 148 | rbuf := make([]byte, 65) 149 | if _, err := io.ReadFull(reader, rbuf); err != nil { 150 | return 151 | } 152 | cnx.clientVersion = rbuf[0] 153 | if cnx.clientVersion != 6 { 154 | log.Print("Unsupported client version - Please run the same version on the server and on the client") 155 | return 156 | } 157 | r := rbuf[1:33] 158 | h0 := rbuf[33:65] 159 | wh0 := auth0(conf, cnx.clientVersion, r) 160 | if subtle.ConstantTimeCompare(wh0, h0) != 1 { 161 | return 162 | } 163 | r2 := make([]byte, 32) 164 | if _, err := rand.Read(r2); err != nil { 165 | log.Fatal(err) 166 | } 167 | h1 := auth1(conf, cnx.clientVersion, h0, r2) 168 | writer.Write([]byte{cnx.clientVersion}) 169 | writer.Write(r2) 170 | writer.Write(h1) 171 | if err := writer.Flush(); err != nil { 172 | log.Print(err) 173 | return 174 | } 175 | remoteIP := cnx.conn.RemoteAddr().(*net.TCPAddr).IP 176 | addToTrustedIPs(conf, remoteIP) 177 | opcode, err := reader.ReadByte() 178 | if err != nil { 179 | return 180 | } 181 | switch opcode { 182 | case byte('G'): 183 | cnx.getOperation(h1, false) 184 | case byte('M'): 185 | cnx.getOperation(h1, true) 186 | case byte('S'): 187 | cnx.storeOperation(h1) 188 | } 189 | } 190 | 191 | // TrustedClients - Clients IPs having recently performed a successful handshake 192 | type TrustedClients struct { 193 | sync.RWMutex 194 | 195 | ips []net.IP 196 | } 197 | 198 | func addToTrustedIPs(conf Conf, ip net.IP) { 199 | trustedClients.Lock() 200 | if uint64(len(trustedClients.ips)) >= conf.TrustedIPCount { 201 | trustedClients.ips = append(trustedClients.ips[1:], ip) 202 | } else { 203 | trustedClients.ips = append(trustedClients.ips, ip) 204 | } 205 | trustedClients.Unlock() 206 | } 207 | 208 | func isIPTrusted(conf Conf, ip net.IP) bool { 209 | trustedClients.RLock() 210 | defer trustedClients.RUnlock() 211 | if len(trustedClients.ips) == 0 { 212 | return true 213 | } 214 | for _, foundIP := range trustedClients.ips { 215 | if foundIP.Equal(ip) { 216 | return true 217 | } 218 | } 219 | return false 220 | } 221 | 222 | func acceptClient(conf Conf, conn net.Conn) { 223 | handleClientConnection(conf, conn) 224 | atomic.AddUint64(&clientsCount, ^uint64(0)) 225 | } 226 | 227 | func maybeAcceptClient(conf Conf, conn net.Conn) { 228 | conn.SetDeadline(time.Now().Add(conf.Timeout)) 229 | remoteIP := conn.RemoteAddr().(*net.TCPAddr).IP 230 | for { 231 | count := atomic.LoadUint64(&clientsCount) 232 | if count >= conf.MaxClients-conf.TrustedIPCount && !isIPTrusted(conf, remoteIP) { 233 | conn.Close() 234 | return 235 | } 236 | if count >= conf.MaxClients { 237 | conn.Close() 238 | return 239 | } else if atomic.CompareAndSwapUint64(&clientsCount, count, count+1) { 240 | break 241 | } 242 | } 243 | go acceptClient(conf, conn) 244 | } 245 | 246 | // RunServer - run a server 247 | func RunServer(conf Conf) { 248 | go handleSignals() 249 | listen, err := net.Listen("tcp", conf.Listen) 250 | if err != nil { 251 | log.Fatal(err) 252 | } 253 | defer listen.Close() 254 | for { 255 | conn, err := listen.Accept() 256 | if err != nil { 257 | log.Fatal(err) 258 | } 259 | maybeAcceptClient(conf, conn) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /signals_bsd.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || netbsd || openbsd 2 | // +build darwin dragonfly freebsd netbsd openbsd 3 | 4 | package main 5 | 6 | import ( 7 | "encoding/binary" 8 | "fmt" 9 | "os" 10 | "os/signal" 11 | "syscall" 12 | "time" 13 | ) 14 | 15 | func handleSignals() { 16 | signals := make(chan os.Signal, 1) 17 | signal.Notify(signals, syscall.SIGINFO) 18 | for { 19 | signal, ok := <-signals 20 | if !ok { 21 | break 22 | } 23 | switch signal { 24 | case syscall.SIGINFO: 25 | storedContent.RLock() 26 | procName := "piknik" 27 | if len(os.Args) >= 1 { 28 | procName = os.Args[0] 29 | } 30 | if storedContent.ts == nil { 31 | fmt.Printf("%v: the clipboard is empty\n", procName) 32 | } else { 33 | elapsed := time.Since(time.Unix(int64(binary.LittleEndian.Uint64(storedContent.ts)), 0)) 34 | if elapsed <= 1 { 35 | fmt.Printf("%v: the clipboard is not empty (last filled a few moments ago)\n", 36 | procName) 37 | } else { 38 | fmt.Printf("%v: the clipboard is not empty (last filled %v minutes ago)\n", 39 | procName, elapsed) 40 | } 41 | } 42 | storedContent.RUnlock() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /signals_notbsd.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin && !dragonfly && !freebsd && !netbsd && !openbsd 2 | // +build !darwin,!dragonfly,!freebsd,!netbsd,!openbsd 3 | 4 | package main 5 | 6 | func handleSignals() {} 7 | -------------------------------------------------------------------------------- /terminal_notty.go: -------------------------------------------------------------------------------- 1 | //go:build (!darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !windows) || (linux && appengine) 2 | // +build !darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!windows linux,appengine 3 | 4 | package main 5 | 6 | // IsTerminal - returns true if the file descriptor is attached to a terminal 7 | func IsTerminal(fd int) bool { 8 | return false 9 | } 10 | -------------------------------------------------------------------------------- /terminal_tty.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || (linux && !appengine) || netbsd || openbsd || windows 2 | // +build darwin dragonfly freebsd linux,!appengine netbsd openbsd windows 3 | 4 | package main 5 | 6 | import "golang.org/x/term" 7 | 8 | // IsTerminal - returns true if the file descriptor is attached to a terminal 9 | func IsTerminal(fd int) bool { 10 | return term.IsTerminal(fd) 11 | } 12 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | set -e 4 | 5 | TMPDIR=${TMPDIR:-/tmp} 6 | PIKNIK_S="./piknik -config ${TMPDIR}/piknik-test-server.toml -server" 7 | PIKNIK_C="./piknik -config ${TMPDIR}/piknik-test-client.toml" 8 | 9 | cat > "${TMPDIR}/piknik-test-server.toml" < "${TMPDIR}/piknik-test-client.toml" < /tmp/pi2 30 | cmp /tmp/pi /tmp/pi2 31 | $PIKNIK_C | $PIKNIK_C -copy 32 | $PIKNIK_C -move > /tmp/pi2 33 | cmp /tmp/pi /tmp/pi2 34 | $PIKNIK_C && exit 1 35 | kill $pid 36 | 37 | echo 38 | echo 'Success!' 39 | echo 40 | -------------------------------------------------------------------------------- /zsh.aliases: -------------------------------------------------------------------------------- 1 | # pko : copy to the clipboard 2 | pko() { 3 | echo "$*" | piknik -copy 4 | } 5 | 6 | # pkf : copy the content of to the clipboard 7 | pkf() { 8 | piknik -copy < $1 9 | } 10 | 11 | # pkc : read the content to copy to the clipboard from STDIN 12 | alias pkc='piknik -copy' 13 | 14 | # pkp : paste the clipboard content 15 | alias pkp='piknik -paste' 16 | 17 | # pkm : move the clipboard content 18 | alias pkm='piknik -move' 19 | 20 | # pkz : delete the clipboard content 21 | alias pkz='piknik -copy < /dev/null' 22 | 23 | # pkfr [] : send a whole directory to the clipboard, as a tar archive 24 | pkfr() { 25 | tar czpvf - ${1:-.} | piknik -copy 26 | } 27 | 28 | # pkpr : extract clipboard content sent using the pkfr command 29 | alias pkpr='piknik -paste | tar xzpvf -' 30 | --------------------------------------------------------------------------------