Gleam SDK for Pocketenv
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add client-side encryption and redaction

+175 -5
+11
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [1.1.0] - 2026-04-02 9 + 10 + ### Added 11 + 12 + - `pocketenv/crypto` module with `seal/1` (NaCl sealed-box encryption via libsodium) and `redact/1` (display-safe masking) 13 + - `pocketenv/sshkeys` module with `get/1` and `put/3` — private keys are encrypted before transmission and a redacted display value is stored alongside 14 + - `secrets.put/3` now encrypts the secret value client-side before sending 15 + - `network.setup_tailscale/2` now encrypts the auth key and stores a redacted display value 16 + - `network.get_tailscale_auth_key/1` now returns the redacted display value 17 + - `enacl` dependency for Erlang libsodium NIF bindings 18 + 8 19 ## [1.0.0] - 2026-04-01 9 20 10 21 ### Added
+2 -1
gleam.toml
··· 1 1 name = "pocketenv" 2 - version = "1.0.0" 2 + version = "1.1.0" 3 3 4 4 description = "Gleam SDK for Pocketenv" 5 5 licences = ["MIT"] ··· 22 22 gleam_http = ">= 4.0.0 and < 5.0.0" 23 23 gleam_httpc = ">= 5.0.0 and < 6.0.0" 24 24 gleam_json = ">= 3.0.0 and < 4.0.0" 25 + enacl = ">= 1.2.1 and < 2.0.0" 25 26 26 27 [dev-dependencies] 27 28 gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
··· 2 2 # You typically do not need to edit this file 3 3 4 4 packages = [ 5 + { name = "enacl", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "enacl", source = "hex", outer_checksum = "67BBBEDDD2564DC899A3DCBC3765CD6AD71629134F1E500A50EC071F0F75E552" }, 5 6 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, 6 7 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, 7 8 { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, ··· 11 12 ] 12 13 13 14 [requirements] 15 + enacl = { version = ">= 1.2.1 and < 2.0.0" } 14 16 gleam_http = { version = ">= 4.0.0 and < 5.0.0" } 15 17 gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } 16 18 gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
+48
src/pocketenv/crypto.gleam
··· 1 + //// Client-side encryption helpers. 2 + //// 3 + //// Sensitive values (secrets, SSH private keys, Tailscale auth keys) are 4 + //// sealed with the server's X25519 public key using NaCl `crypto_box_seal` 5 + //// before transmission. The server holds the corresponding private key and 6 + //// is the only party that can decrypt the values. 7 + 8 + import gleam/bit_array 9 + import gleam/string 10 + 11 + /// The server's X25519 public key used for all client-side encryption. 12 + const public_key = "2bf96e12d109e6948046a7803ef1696e12c11f04f20a6ce64dbd4bcd93db9341" 13 + 14 + /// Encrypts `message` using NaCl sealed-box with the hardcoded server public key. 15 + /// Returns a URL-safe base64 string with no padding. 16 + pub fn seal(message: String) -> String { 17 + let pk = decode_hex(public_key) 18 + let msg = bit_array.from_string(message) 19 + box_seal(msg, pk) 20 + |> base64_encode() 21 + |> string.replace("+", "-") 22 + |> string.replace("/", "_") 23 + |> string.replace("=", "") 24 + } 25 + 26 + /// Returns a redacted representation of `value` suitable for display. 27 + /// Preserves the first 11 and last 3 characters; replaces the middle with `*`. 28 + pub fn redact(value: String) -> String { 29 + let len = string.length(value) 30 + case len > 14 { 31 + True -> 32 + string.slice(value, 0, 11) 33 + <> string.repeat("*", len - 14) 34 + <> string.slice(value, len - 3, 3) 35 + False -> string.repeat("*", len) 36 + } 37 + } 38 + 39 + // --- FFI --- 40 + 41 + @external(erlang, "enacl", "box_seal") 42 + fn box_seal(message: BitArray, public_key: BitArray) -> BitArray 43 + 44 + @external(erlang, "binary", "decode_hex") 45 + fn decode_hex(hex: String) -> BitArray 46 + 47 + @external(erlang, "base64", "encode") 48 + fn base64_encode(data: BitArray) -> String
+14 -3
src/pocketenv/network.gleam
··· 8 8 import gleam/option.{type Option, None, Some} 9 9 import gleam/result 10 10 import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post} 11 + import pocketenv/crypto 11 12 import pocketenv/sandbox.{type ConnectedSandbox} 12 13 13 14 /// Exposes `port` on the sandbox to the internet. ··· 65 66 } 66 67 67 68 /// Stores a Tailscale auth key for the sandbox, enabling private network access. 69 + /// The key is validated to start with `"tskey-auth-"`, encrypted client-side, 70 + /// and stored with a redacted display value. 68 71 /// 69 72 /// ## Example 70 73 /// ··· 75 78 sb: ConnectedSandbox, 76 79 auth_key: String, 77 80 ) -> Result(Nil, PocketenvError) { 81 + let encrypted = crypto.seal(auth_key) 82 + let redacted = crypto.redact(auth_key) 78 83 let body = 79 - json.to_string(json.object([#("tailscaleAuthKey", json.string(auth_key))])) 84 + json.to_string( 85 + json.object([ 86 + #("tailscaleAuthKey", json.string(encrypted)), 87 + #("redacted", json.string(redacted)), 88 + ]), 89 + ) 80 90 use _ <- result.try(do_post( 81 91 sb.client, 82 92 "/xrpc/io.pocketenv.sandbox.putTailscaleAuthKey", ··· 86 96 Ok(Nil) 87 97 } 88 98 89 - /// Retrieves the stored Tailscale auth key for the sandbox, if any. 99 + /// Retrieves the redacted display value of the stored Tailscale auth key, if any. 100 + /// The actual auth key is encrypted server-side and never returned in plaintext. 90 101 /// 91 102 /// ## Example 92 103 /// ··· 103 114 ) 104 115 json.parse(body, { 105 116 use key <- decode.optional_field( 106 - "tailscaleAuthKey", 117 + "redacted", 107 118 None, 108 119 decode.optional(decode.string), 109 120 )
+5 -1
src/pocketenv/secrets.gleam
··· 11 11 import gleam/option.{type Option, None, Some} 12 12 import gleam/result 13 13 import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post} 14 + import pocketenv/crypto 14 15 import pocketenv/sandbox.{type ConnectedSandbox} 15 16 16 17 /// A secret stored in a sandbox. Only the `name` is exposed; the value is never returned. ··· 52 53 } 53 54 54 55 /// Creates or updates a secret named `name` with `value`. 56 + /// The value is encrypted client-side with the server's public key before 57 + /// transmission and is never returned by the API. 55 58 /// 56 59 /// ## Example 57 60 /// ··· 63 66 name: String, 64 67 value: String, 65 68 ) -> Result(Nil, PocketenvError) { 69 + let encrypted = crypto.seal(value) 66 70 let body = 67 71 json.to_string( 68 72 json.object([ ··· 71 75 json.object([ 72 76 #("sandboxId", json.string(sb.data.id)), 73 77 #("name", json.string(name)), 74 - #("value", json.string(value)), 78 + #("value", json.string(encrypted)), 75 79 ]), 76 80 ), 77 81 ]),
+93
src/pocketenv/sshkeys.gleam
··· 1 + //// Manage SSH key pairs attached to a sandbox. 2 + //// 3 + //// The private key is encrypted client-side with the server's public key 4 + //// before transmission and is never returned in plaintext. A redacted 5 + //// display value is stored alongside so users can recognise which key is 6 + //// configured without exposing the full value. 7 + 8 + import gleam/dynamic/decode 9 + import gleam/json 10 + import gleam/option.{type Option, None, Some} 11 + import gleam/result 12 + import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post} 13 + import pocketenv/crypto 14 + import pocketenv/sandbox.{type ConnectedSandbox} 15 + 16 + /// SSH key pair stored in a sandbox. 17 + /// `private_key` is the encrypted ciphertext; `public_key` is plaintext; 18 + /// `redacted` is a display-safe version of the original private key. 19 + pub type SshKeys { 20 + SshKeys(private_key: String, public_key: String, redacted: String) 21 + } 22 + 23 + /// Retrieves the stored SSH keys for the sandbox, if any. 24 + /// 25 + /// ## Example 26 + /// 27 + /// ```gleam 28 + /// let assert Ok(keys) = sb |> sshkeys.get() 29 + /// ``` 30 + pub fn get(sb: ConnectedSandbox) -> Result(Option(SshKeys), PocketenvError) { 31 + use body <- result.try( 32 + do_get(sb.client, "/xrpc/io.pocketenv.sandbox.getSshKeys", [ 33 + #("id", sb.data.id), 34 + ]), 35 + ) 36 + json.parse(body, { 37 + use private_key <- decode.optional_field( 38 + "privateKey", 39 + None, 40 + decode.optional(decode.string), 41 + ) 42 + use public_key <- decode.optional_field( 43 + "publicKey", 44 + None, 45 + decode.optional(decode.string), 46 + ) 47 + use redacted <- decode.optional_field( 48 + "redacted", 49 + None, 50 + decode.optional(decode.string), 51 + ) 52 + decode.success(case private_key, public_key { 53 + Some(priv), Some(pubk) -> 54 + Some(SshKeys( 55 + private_key: priv, 56 + public_key: pubk, 57 + redacted: option.unwrap(redacted, ""), 58 + )) 59 + _, _ -> None 60 + }) 61 + }) 62 + |> result.map_error(JsonDecodeError) 63 + } 64 + 65 + /// Stores an SSH key pair, encrypting `private_key` before transmission. 66 + /// A redacted display value is computed automatically. 67 + /// 68 + /// ## Example 69 + /// 70 + /// ```gleam 71 + /// let assert Ok(Nil) = sb |> sshkeys.put(private_key, public_key) 72 + /// ``` 73 + pub fn put( 74 + sb: ConnectedSandbox, 75 + private_key: String, 76 + public_key: String, 77 + ) -> Result(Nil, PocketenvError) { 78 + let encrypted = crypto.seal(private_key) 79 + let redacted = crypto.redact(private_key) 80 + let body = 81 + json.to_string( 82 + json.object([ 83 + #("id", json.string(sb.data.id)), 84 + #("privateKey", json.string(encrypted)), 85 + #("publicKey", json.string(public_key)), 86 + #("redacted", json.string(redacted)), 87 + ]), 88 + ) 89 + use _ <- result.try( 90 + do_post(sb.client, "/xrpc/io.pocketenv.sandbox.putSshKeys", [], body), 91 + ) 92 + Ok(Nil) 93 + }