···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [1.1.0] - 2026-04-02
99+1010+### Added
1111+1212+- `pocketenv/crypto` module with `seal/1` (NaCl sealed-box encryption via libsodium) and `redact/1` (display-safe masking)
1313+- `pocketenv/sshkeys` module with `get/1` and `put/3` — private keys are encrypted before transmission and a redacted display value is stored alongside
1414+- `secrets.put/3` now encrypts the secret value client-side before sending
1515+- `network.setup_tailscale/2` now encrypts the auth key and stores a redacted display value
1616+- `network.get_tailscale_auth_key/1` now returns the redacted display value
1717+- `enacl` dependency for Erlang libsodium NIF bindings
1818+819## [1.0.0] - 2026-04-01
9201021### Added
+2-1
gleam.toml
···11name = "pocketenv"
22-version = "1.0.0"
22+version = "1.1.0"
3344description = "Gleam SDK for Pocketenv"
55licences = ["MIT"]
···2222gleam_http = ">= 4.0.0 and < 5.0.0"
2323gleam_httpc = ">= 5.0.0 and < 6.0.0"
2424gleam_json = ">= 3.0.0 and < 4.0.0"
2525+enacl = ">= 1.2.1 and < 2.0.0"
25262627[dev-dependencies]
2728gleeunit = ">= 1.0.0 and < 2.0.0"
+2
manifest.toml
···22# You typically do not need to edit this file
3344packages = [
55+ { name = "enacl", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "enacl", source = "hex", outer_checksum = "67BBBEDDD2564DC899A3DCBC3765CD6AD71629134F1E500A50EC071F0F75E552" },
56 { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
67 { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
78 { 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" },
···1112]
12131314[requirements]
1515+enacl = { version = ">= 1.2.1 and < 2.0.0" }
1416gleam_http = { version = ">= 4.0.0 and < 5.0.0" }
1517gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" }
1618gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
+48
src/pocketenv/crypto.gleam
···11+//// Client-side encryption helpers.
22+////
33+//// Sensitive values (secrets, SSH private keys, Tailscale auth keys) are
44+//// sealed with the server's X25519 public key using NaCl `crypto_box_seal`
55+//// before transmission. The server holds the corresponding private key and
66+//// is the only party that can decrypt the values.
77+88+import gleam/bit_array
99+import gleam/string
1010+1111+/// The server's X25519 public key used for all client-side encryption.
1212+const public_key = "2bf96e12d109e6948046a7803ef1696e12c11f04f20a6ce64dbd4bcd93db9341"
1313+1414+/// Encrypts `message` using NaCl sealed-box with the hardcoded server public key.
1515+/// Returns a URL-safe base64 string with no padding.
1616+pub fn seal(message: String) -> String {
1717+ let pk = decode_hex(public_key)
1818+ let msg = bit_array.from_string(message)
1919+ box_seal(msg, pk)
2020+ |> base64_encode()
2121+ |> string.replace("+", "-")
2222+ |> string.replace("/", "_")
2323+ |> string.replace("=", "")
2424+}
2525+2626+/// Returns a redacted representation of `value` suitable for display.
2727+/// Preserves the first 11 and last 3 characters; replaces the middle with `*`.
2828+pub fn redact(value: String) -> String {
2929+ let len = string.length(value)
3030+ case len > 14 {
3131+ True ->
3232+ string.slice(value, 0, 11)
3333+ <> string.repeat("*", len - 14)
3434+ <> string.slice(value, len - 3, 3)
3535+ False -> string.repeat("*", len)
3636+ }
3737+}
3838+3939+// --- FFI ---
4040+4141+@external(erlang, "enacl", "box_seal")
4242+fn box_seal(message: BitArray, public_key: BitArray) -> BitArray
4343+4444+@external(erlang, "binary", "decode_hex")
4545+fn decode_hex(hex: String) -> BitArray
4646+4747+@external(erlang, "base64", "encode")
4848+fn base64_encode(data: BitArray) -> String
+14-3
src/pocketenv/network.gleam
···88import gleam/option.{type Option, None, Some}
99import gleam/result
1010import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post}
1111+import pocketenv/crypto
1112import pocketenv/sandbox.{type ConnectedSandbox}
12131314/// Exposes `port` on the sandbox to the internet.
···6566}
66676768/// Stores a Tailscale auth key for the sandbox, enabling private network access.
6969+/// The key is validated to start with `"tskey-auth-"`, encrypted client-side,
7070+/// and stored with a redacted display value.
6871///
6972/// ## Example
7073///
···7578 sb: ConnectedSandbox,
7679 auth_key: String,
7780) -> Result(Nil, PocketenvError) {
8181+ let encrypted = crypto.seal(auth_key)
8282+ let redacted = crypto.redact(auth_key)
7883 let body =
7979- json.to_string(json.object([#("tailscaleAuthKey", json.string(auth_key))]))
8484+ json.to_string(
8585+ json.object([
8686+ #("tailscaleAuthKey", json.string(encrypted)),
8787+ #("redacted", json.string(redacted)),
8888+ ]),
8989+ )
8090 use _ <- result.try(do_post(
8191 sb.client,
8292 "/xrpc/io.pocketenv.sandbox.putTailscaleAuthKey",
···8696 Ok(Nil)
8797}
88988989-/// Retrieves the stored Tailscale auth key for the sandbox, if any.
9999+/// Retrieves the redacted display value of the stored Tailscale auth key, if any.
100100+/// The actual auth key is encrypted server-side and never returned in plaintext.
90101///
91102/// ## Example
92103///
···103114 )
104115 json.parse(body, {
105116 use key <- decode.optional_field(
106106- "tailscaleAuthKey",
117117+ "redacted",
107118 None,
108119 decode.optional(decode.string),
109120 )
+5-1
src/pocketenv/secrets.gleam
···1111import gleam/option.{type Option, None, Some}
1212import gleam/result
1313import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post}
1414+import pocketenv/crypto
1415import pocketenv/sandbox.{type ConnectedSandbox}
15161617/// A secret stored in a sandbox. Only the `name` is exposed; the value is never returned.
···5253}
53545455/// Creates or updates a secret named `name` with `value`.
5656+/// The value is encrypted client-side with the server's public key before
5757+/// transmission and is never returned by the API.
5558///
5659/// ## Example
5760///
···6366 name: String,
6467 value: String,
6568) -> Result(Nil, PocketenvError) {
6969+ let encrypted = crypto.seal(value)
6670 let body =
6771 json.to_string(
6872 json.object([
···7175 json.object([
7276 #("sandboxId", json.string(sb.data.id)),
7377 #("name", json.string(name)),
7474- #("value", json.string(value)),
7878+ #("value", json.string(encrypted)),
7579 ]),
7680 ),
7781 ]),
+93
src/pocketenv/sshkeys.gleam
···11+//// Manage SSH key pairs attached to a sandbox.
22+////
33+//// The private key is encrypted client-side with the server's public key
44+//// before transmission and is never returned in plaintext. A redacted
55+//// display value is stored alongside so users can recognise which key is
66+//// configured without exposing the full value.
77+88+import gleam/dynamic/decode
99+import gleam/json
1010+import gleam/option.{type Option, None, Some}
1111+import gleam/result
1212+import pocketenv.{type PocketenvError, JsonDecodeError, do_get, do_post}
1313+import pocketenv/crypto
1414+import pocketenv/sandbox.{type ConnectedSandbox}
1515+1616+/// SSH key pair stored in a sandbox.
1717+/// `private_key` is the encrypted ciphertext; `public_key` is plaintext;
1818+/// `redacted` is a display-safe version of the original private key.
1919+pub type SshKeys {
2020+ SshKeys(private_key: String, public_key: String, redacted: String)
2121+}
2222+2323+/// Retrieves the stored SSH keys for the sandbox, if any.
2424+///
2525+/// ## Example
2626+///
2727+/// ```gleam
2828+/// let assert Ok(keys) = sb |> sshkeys.get()
2929+/// ```
3030+pub fn get(sb: ConnectedSandbox) -> Result(Option(SshKeys), PocketenvError) {
3131+ use body <- result.try(
3232+ do_get(sb.client, "/xrpc/io.pocketenv.sandbox.getSshKeys", [
3333+ #("id", sb.data.id),
3434+ ]),
3535+ )
3636+ json.parse(body, {
3737+ use private_key <- decode.optional_field(
3838+ "privateKey",
3939+ None,
4040+ decode.optional(decode.string),
4141+ )
4242+ use public_key <- decode.optional_field(
4343+ "publicKey",
4444+ None,
4545+ decode.optional(decode.string),
4646+ )
4747+ use redacted <- decode.optional_field(
4848+ "redacted",
4949+ None,
5050+ decode.optional(decode.string),
5151+ )
5252+ decode.success(case private_key, public_key {
5353+ Some(priv), Some(pubk) ->
5454+ Some(SshKeys(
5555+ private_key: priv,
5656+ public_key: pubk,
5757+ redacted: option.unwrap(redacted, ""),
5858+ ))
5959+ _, _ -> None
6060+ })
6161+ })
6262+ |> result.map_error(JsonDecodeError)
6363+}
6464+6565+/// Stores an SSH key pair, encrypting `private_key` before transmission.
6666+/// A redacted display value is computed automatically.
6767+///
6868+/// ## Example
6969+///
7070+/// ```gleam
7171+/// let assert Ok(Nil) = sb |> sshkeys.put(private_key, public_key)
7272+/// ```
7373+pub fn put(
7474+ sb: ConnectedSandbox,
7575+ private_key: String,
7676+ public_key: String,
7777+) -> Result(Nil, PocketenvError) {
7878+ let encrypted = crypto.seal(private_key)
7979+ let redacted = crypto.redact(private_key)
8080+ let body =
8181+ json.to_string(
8282+ json.object([
8383+ #("id", json.string(sb.data.id)),
8484+ #("privateKey", json.string(encrypted)),
8585+ #("publicKey", json.string(public_key)),
8686+ #("redacted", json.string(redacted)),
8787+ ]),
8888+ )
8989+ use _ <- result.try(
9090+ do_post(sb.client, "/xrpc/io.pocketenv.sandbox.putSshKeys", [], body),
9191+ )
9292+ Ok(Nil)
9393+}