diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bb65a56 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,123 @@ +# Claude AI Instructions for un-inception + +## Project Overview + +UN CLI Inception - The UN CLI written in every language it can execute. 42 implementations, one unified interface. + +## Authentication + +**HMAC Authentication** (current): +```bash +export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx" +export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" +``` + +The auth pattern for all implementations: +- `Authorization: Bearer {public_key}` +- `X-Timestamp: {unix_seconds}` +- `X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body")` + +Legacy `UNSANDBOX_API_KEY` is still supported as fallback. + +### HMAC Dependencies + +Each implementation needs HMAC-SHA256 capability: + +| Language | Dependency | Install | +|----------|------------|---------| +| Python | `hmac`, `hashlib` | Built-in | +| JavaScript/TS | `crypto` | Built-in (Node.js) | +| Ruby | `openssl` | Built-in | +| Go | `crypto/hmac` | Built-in | +| PHP | `hash_hmac()` | Built-in | +| Perl | `Digest::SHA` | Core module | +| Lua | `openssl` CLI | `apt install openssl` | +| Bash | `openssl` CLI | `apt install openssl` | +| Rust | `hmac`, `sha2` | `cargo add hmac sha2` | +| C/C++ | OpenSSL | `apt install libssl-dev` + `-lssl -lcrypto` | +| Java | `javax.crypto` | Built-in | +| C# | `System.Security.Cryptography` | Built-in | +| Haskell | `cryptonite` | `cabal install cryptonite` | +| Clojure | `buddy-core` | Add to deps.edn | +| Erlang/Elixir | `:crypto` | OTP built-in | +| Julia | `SHA` | Built-in | +| R | `openssl` | `install.packages("openssl")` | + +**Note**: Languages without native HMAC (Lua, Bash, AWK, Forth) shell out to `openssl dgst -sha256 -hmac`. + +## The Inception Matrix - Testing Languages Without Local Interpreters + +**CRITICAL INSIGHT**: Use `un` (the C implementation) to run tests for languages not installed locally! + +If a language isn't available on the local machine (e.g., PHP, Julia, Haskell), run the UN implementation through unsandbox itself: + +```bash +# Key flags: +# -n semitrusted = allow network access so inner script can call API +# -e KEY=VALUE = pass API keys to inner script + +# Don't have PHP installed? Run un.php through unsandbox! +un -n semitrusted -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY un.php test/fib.py + +# Don't have Julia? Run un.jl through unsandbox! +un -n semitrusted -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY un.jl test/fib.py +``` + +This is the **inception** - using un to run un to run code. Each layer executes through unsandbox's remote execution API. + +### Inception Test Matrix + +To test ALL 42 implementations regardless of local interpreters: + +```bash +# Use un (C implementation) to test all others through unsandbox +for impl in un.py un.js un.rb un.go un.php un.pl un.lua; do + echo "Testing $impl..." + un -n semitrusted \ + -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY \ + -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY \ + "$impl" test/fib.py +done +``` + +The test suite in `tests/run_all_tests.sh` currently skips languages without local interpreters. Use the inception pattern with `un` to achieve 100% test coverage. + +## Directory Structure + +- `un.*` - 42 UN CLI implementations (un.py, un.js, un.rb, un.go, etc.) +- `tests/` - Test suites for each implementation +- `test/` - Shared test files (fib.py, fib.sh, etc.) + +## Running Tests + +```bash +# Set auth +export UNSANDBOX_PUBLIC_KEY="unsb-pk-zhi3-b6cv-jvqc-uven" +export UNSANDBOX_SECRET_KEY="unsb-sk-z4a93-a33xy-7u7eh-pngpg" + +# Run all available tests +./tests/run_all_tests.sh + +# Run individual test +python3 tests/test_un_py.py +lua tests/test_un_lua.lua +bash tests/test_un_sh.sh +``` + +## Common Test Fixes + +### Bash arithmetic in `set -e` mode +The pattern `((VAR++))` returns exit code 1 when VAR is 0. Use `VAR=$((VAR + 1))` instead. + +### Script directory detection +- **Lua**: `arg[0]:match("(.*/)") or "./"` +- **TypeScript**: `path.dirname(process.argv[1] || __filename)` +- **Bash**: `SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` + +### Shebang lines +Shebang MUST be on line 1, not buried in license headers. + +## Related Repos + +- `~/git/unsandbox.com/` - Portal (contains un.c CLI at cli/un.c) +- `~/git/api.unsandbox.com/` - API server diff --git a/README.md b/README.md index d4f82ad..ab5870d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ The UN CLI written in every language it can execute. **42 implementations, one u git clone https://github.com/russellballestrini/un-inception.git cd un-inception -# Set your API key -export UNSANDBOX_API_KEY=your_key_here +# Set your API keys (HMAC authentication) +export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx" +export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" # Run any implementation python3 un.py test/fib.py @@ -24,6 +25,41 @@ go run un.go test/fib.py # fib(10) = 55 ``` +## HMAC Authentication + +All implementations use HMAC-SHA256 for request signing: + +``` +Authorization: Bearer {public_key} +X-Timestamp: {unix_seconds} +X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") +``` + +### HMAC Dependencies by Language + +| Language | HMAC Library | Notes | +|----------|--------------|-------| +| **Python** | `hmac`, `hashlib` | Standard library | +| **JavaScript** | `crypto` | Node.js built-in | +| **TypeScript** | `crypto` | Node.js built-in | +| **Ruby** | `openssl` | Standard library | +| **Go** | `crypto/hmac`, `crypto/sha256` | Standard library | +| **Rust** | `hmac`, `sha2` | Crates (add to Cargo.toml) | +| **PHP** | `hash_hmac()` | Built-in function | +| **Perl** | `Digest::SHA` | Core module | +| **Lua** | `openssl` CLI | Shells out to `openssl dgst` | +| **Bash** | `openssl` CLI | Uses `openssl dgst -sha256 -hmac` | +| **C/C++** | OpenSSL | Link with `-lssl -lcrypto` | +| **Java** | `javax.crypto` | Standard library | +| **C#** | `System.Security.Cryptography` | .NET built-in | +| **Haskell** | `cryptonite` | Hackage package | +| **Clojure** | `buddy-core` | Clojars dependency | +| **Erlang/Elixir** | `:crypto` | OTP built-in | +| **Julia** | `SHA` | Standard library | +| **R** | `openssl` | CRAN package | + +Most languages have HMAC-SHA256 in their standard library. Languages without native support (Lua, Bash, AWK) shell out to the `openssl` command-line tool. + ## Implementations | Language | File | Category | @@ -121,9 +157,35 @@ Each implementation supports: # Output: fib(10) = 55 # Run the full test matrix (requires API key) -./tests/run_matrix.sh +./tests/run_all_tests.sh ``` +## The Inception Matrix + +**Use un to run un inside un!** Don't have a language installed locally? Run its implementation through unsandbox using `un` (the C implementation): + +```bash +# Pass API keys via -e so the inner un.* can call the API +# Use -n semitrusted so inner script can reach api.unsandbox.com + +# Don't have PHP? Run un.php through unsandbox! +un -n semitrusted -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY un.php test/fib.py + +# Don't have Julia? Run un.jl through unsandbox! +un -n semitrusted -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY un.jl test/fib.py + +# Test ALL implementations regardless of local interpreters +for impl in un.py un.js un.rb un.go un.php un.pl un.lua; do + echo "Testing $impl..." + un -n semitrusted \ + -e UNSANDBOX_PUBLIC_KEY=$UNSANDBOX_PUBLIC_KEY \ + -e UNSANDBOX_SECRET_KEY=$UNSANDBOX_SECRET_KEY \ + "$impl" test/fib.py +done +``` + +This is the **inception** - each layer executes through unsandbox's remote API, so you can test any implementation using `un` (the canonical C implementation) as the runner. + ## Links - [unsandbox.com](https://unsandbox.com) - Remote code execution API diff --git a/Un.cs b/Un.cs index ea908ac..0a7213d 100644 --- a/Un.cs +++ b/Un.cs @@ -45,6 +45,7 @@ using System.Collections.Generic; using System.IO; using System.Net; using System.Text; +using System.Security.Cryptography; class Un { @@ -110,7 +111,7 @@ class Un static void CmdExecute(Args args) { - string apiKey = GetApiKey(args.ApiKey); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); string code = File.ReadAllText(args.SourceFile); string language = DetectLanguage(args.SourceFile); @@ -165,7 +166,7 @@ class Un payload["vcpu"] = args.Vcpu; } - var result = ApiRequest("/execute", "POST", payload, apiKey); + var result = ApiRequest("/execute", "POST", payload, publicKey, secretKey); if (result.ContainsKey("stdout") && !string.IsNullOrEmpty((string)result["stdout"])) { @@ -197,11 +198,11 @@ class Un static void CmdSession(Args args) { - string apiKey = GetApiKey(args.ApiKey); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.SessionList) { - var result = ApiRequest("/sessions", "GET", null, apiKey); + var result = ApiRequest("/sessions", "GET", null, publicKey, secretKey); var sessions = result.ContainsKey("sessions") ? result["sessions"] as List : null; if (sessions == null || sessions.Count == 0) { @@ -224,7 +225,7 @@ class Un if (args.SessionKill != null) { - ApiRequest($"/sessions/{args.SessionKill}", "DELETE", null, apiKey); + ApiRequest($"/sessions/{args.SessionKill}", "DELETE", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Session terminated: {args.SessionKill}{RESET}"); return; } @@ -243,16 +244,16 @@ class Un } Console.WriteLine($"{YELLOW}Creating session...{RESET}"); - var createResult = ApiRequest("/sessions", "POST", payload, apiKey); + var createResult = ApiRequest("/sessions", "POST", payload, publicKey, secretKey); Console.WriteLine($"{GREEN}Session created: {createResult["id"]}{RESET}"); Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); } static void CmdKey(Args args) { - string apiKey = GetApiKey(args.ApiKey); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - var result = ApiRequest("/keys/validate", "POST", null, apiKey); + var result = ApiRequest("/keys/validate", "POST", null, publicKey, secretKey); if (!result.ContainsKey("valid")) { @@ -335,11 +336,11 @@ class Un static void CmdService(Args args) { - string apiKey = GetApiKey(args.ApiKey); + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.ServiceList) { - var result = ApiRequest("/services", "GET", null, apiKey); + var result = ApiRequest("/services", "GET", null, publicKey, secretKey); var services = result.ContainsKey("services") ? result["services"] as List : null; if (services == null || services.Count == 0) { @@ -366,42 +367,42 @@ class Un if (args.ServiceInfo != null) { - var result = ApiRequest($"/services/{args.ServiceInfo}", "GET", null, apiKey); + var result = ApiRequest($"/services/{args.ServiceInfo}", "GET", null, publicKey, secretKey); Console.WriteLine(ToJson(result)); return; } if (args.ServiceLogs != null) { - var result = ApiRequest($"/services/{args.ServiceLogs}/logs", "GET", null, apiKey); + var result = ApiRequest($"/services/{args.ServiceLogs}/logs", "GET", null, publicKey, secretKey); Console.WriteLine(result.ContainsKey("logs") ? result["logs"] : ""); return; } if (args.ServiceTail != null) { - var result = ApiRequest($"/services/{args.ServiceTail}/logs?lines=9000", "GET", null, apiKey); + var result = ApiRequest($"/services/{args.ServiceTail}/logs?lines=9000", "GET", null, publicKey, secretKey); Console.WriteLine(result.ContainsKey("logs") ? result["logs"] : ""); return; } if (args.ServiceSleep != null) { - ApiRequest($"/services/{args.ServiceSleep}/sleep", "POST", null, apiKey); + ApiRequest($"/services/{args.ServiceSleep}/sleep", "POST", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service sleeping: {args.ServiceSleep}{RESET}"); return; } if (args.ServiceWake != null) { - ApiRequest($"/services/{args.ServiceWake}/wake", "POST", null, apiKey); + ApiRequest($"/services/{args.ServiceWake}/wake", "POST", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service waking: {args.ServiceWake}{RESET}"); return; } if (args.ServiceDestroy != null) { - ApiRequest($"/services/{args.ServiceDestroy}", "DELETE", null, apiKey); + ApiRequest($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } @@ -412,7 +413,7 @@ class Un { ["command"] = args.ServiceCommand }; - var result = ApiRequest($"/services/{args.ServiceExecute}/execute", "POST", payload, apiKey); + var result = ApiRequest($"/services/{args.ServiceExecute}/execute", "POST", payload, publicKey, secretKey); if (result.ContainsKey("stdout") && !string.IsNullOrEmpty((string)result["stdout"])) { Console.Write($"{BLUE}{result["stdout"]}{RESET}"); @@ -431,7 +432,7 @@ class Un { ["command"] = "cat /tmp/bootstrap.sh" }; - var result = ApiRequest($"/services/{args.ServiceDumpBootstrap}/execute", "POST", payload, apiKey); + var result = ApiRequest($"/services/{args.ServiceDumpBootstrap}/execute", "POST", payload, publicKey, secretKey); var bootstrap = result.ContainsKey("stdout") ? (string)result["stdout"] : null; if (!string.IsNullOrEmpty(bootstrap)) @@ -494,7 +495,7 @@ class Un payload["vcpu"] = args.Vcpu; } - var result = ApiRequest("/services", "POST", payload, apiKey); + var result = ApiRequest("/services", "POST", payload, publicKey, secretKey); Console.WriteLine($"{GREEN}Service created: {result["id"]}{RESET}"); Console.WriteLine($"Name: {result["name"]}"); if (result.ContainsKey("url")) @@ -508,15 +509,24 @@ class Un Environment.Exit(1); } - static string GetApiKey(string argsKey) + static (string, string) GetApiKeys(string argsKey) { - string key = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); - if (string.IsNullOrEmpty(key)) + string publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); + string secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) { - Console.Error.WriteLine($"{RED}Error: UNSANDBOX_API_KEY not set{RESET}"); - Environment.Exit(1); + string legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); + if (string.IsNullOrEmpty(legacyKey)) + { + Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); + Environment.Exit(1); + } + return (legacyKey, null); } - return key; + + return (publicKey, secretKey); } static string DetectLanguage(string filename) @@ -534,20 +544,46 @@ class Un return ExtMap[ext]; } - static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string apiKey) + static Dictionary ApiRequest(string endpoint, string method, Dictionary data, string publicKey, string secretKey) { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; HttpWebRequest request = (HttpWebRequest)WebRequest.Create(API_BASE + endpoint); request.Method = method; - request.Headers.Add("Authorization", $"Bearer {apiKey}"); request.ContentType = "application/json"; request.Timeout = 300000; + string body = ""; if (data != null) { - string json = ToJson(data); - byte[] bytes = Encoding.UTF8.GetBytes(json); + body = ToJson(data); + } + + // Add HMAC authentication headers if secretKey is provided + if (!string.IsNullOrEmpty(secretKey)) + { + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string message = $"{timestamp}:{method}:{endpoint}:{body}"; + + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))) + { + byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + string signature = BitConverter.ToString(hash).Replace("-", "").ToLower(); + + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + } + else + { + // Legacy API key authentication + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + if (data != null) + { + byte[] bytes = Encoding.UTF8.GetBytes(body); request.ContentLength = bytes.Length; using (Stream stream = request.GetRequestStream()) { diff --git a/Un.java b/Un.java index 7d6dbc2..bccf8eb 100644 --- a/Un.java +++ b/Un.java @@ -45,6 +45,8 @@ import java.net.*; import java.nio.file.*; import java.util.*; import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; public class Un { private static final String API_BASE = "https://api.unsandbox.com"; @@ -94,7 +96,9 @@ public class Un { } private static void cmdExecute(Args args) throws Exception { - String apiKey = getApiKey(args.apiKey); + String[] keys = getApiKeys(args.apiKey); + String publicKey = keys[0]; + String secretKey = keys[1]; String code = Files.readString(Paths.get(args.sourceFile)); String language = detectLanguage(args.sourceFile); @@ -137,7 +141,7 @@ public class Un { payload.put("vcpu", args.vcpu); } - Map result = apiRequest("/execute", "POST", payload, apiKey); + Map result = apiRequest("/execute", "POST", payload, publicKey, secretKey); String stdout = (String) result.get("stdout"); String stderr = (String) result.get("stderr"); @@ -168,10 +172,12 @@ public class Un { } private static void cmdSession(Args args) throws Exception { - String apiKey = getApiKey(args.apiKey); + String[] keys = getApiKeys(args.apiKey); + String publicKey = keys[0]; + String secretKey = keys[1]; if (args.sessionList) { - Map result = apiRequest("/sessions", "GET", null, apiKey); + Map result = apiRequest("/sessions", "GET", null, publicKey, secretKey); @SuppressWarnings("unchecked") List> sessions = (List>) result.get("sessions"); if (sessions == null || sessions.isEmpty()) { @@ -190,7 +196,7 @@ public class Un { } if (args.sessionKill != null) { - apiRequest("/sessions/" + args.sessionKill, "DELETE", null, apiKey); + apiRequest("/sessions/" + args.sessionKill, "DELETE", null, publicKey, secretKey); System.out.println(GREEN + "Session terminated: " + args.sessionKill + RESET); return; } @@ -205,16 +211,18 @@ public class Un { } System.out.println(YELLOW + "Creating session..." + RESET); - Map result = apiRequest("/sessions", "POST", payload, apiKey); + Map result = apiRequest("/sessions", "POST", payload, publicKey, secretKey); System.out.println(GREEN + "Session created: " + result.getOrDefault("id", "N/A") + RESET); System.out.println(YELLOW + "(Interactive sessions require WebSocket - use un2 for full support)" + RESET); } private static void cmdService(Args args) throws Exception { - String apiKey = getApiKey(args.apiKey); + String[] keys = getApiKeys(args.apiKey); + String publicKey = keys[0]; + String secretKey = keys[1]; if (args.serviceList) { - Map result = apiRequest("/services", "GET", null, apiKey); + Map result = apiRequest("/services", "GET", null, publicKey, secretKey); @SuppressWarnings("unchecked") List> services = (List>) result.get("services"); if (services == null || services.isEmpty()) { @@ -239,37 +247,37 @@ public class Un { } if (args.serviceInfo != null) { - Map result = apiRequest("/services/" + args.serviceInfo, "GET", null, apiKey); + Map result = apiRequest("/services/" + args.serviceInfo, "GET", null, publicKey, secretKey); System.out.println(toJson(result)); return; } if (args.serviceLogs != null) { - Map result = apiRequest("/services/" + args.serviceLogs + "/logs", "GET", null, apiKey); + Map result = apiRequest("/services/" + args.serviceLogs + "/logs", "GET", null, publicKey, secretKey); System.out.println(result.getOrDefault("logs", "")); return; } if (args.serviceTail != null) { - Map result = apiRequest("/services/" + args.serviceTail + "/logs?lines=9000", "GET", null, apiKey); + Map result = apiRequest("/services/" + args.serviceTail + "/logs?lines=9000", "GET", null, publicKey, secretKey); System.out.println(result.getOrDefault("logs", "")); return; } if (args.serviceSleep != null) { - apiRequest("/services/" + args.serviceSleep + "/sleep", "POST", null, apiKey); + apiRequest("/services/" + args.serviceSleep + "/sleep", "POST", null, publicKey, secretKey); System.out.println(GREEN + "Service sleeping: " + args.serviceSleep + RESET); return; } if (args.serviceWake != null) { - apiRequest("/services/" + args.serviceWake + "/wake", "POST", null, apiKey); + apiRequest("/services/" + args.serviceWake + "/wake", "POST", null, publicKey, secretKey); System.out.println(GREEN + "Service waking: " + args.serviceWake + RESET); return; } if (args.serviceDestroy != null) { - apiRequest("/services/" + args.serviceDestroy, "DELETE", null, apiKey); + apiRequest("/services/" + args.serviceDestroy, "DELETE", null, publicKey, secretKey); System.out.println(GREEN + "Service destroyed: " + args.serviceDestroy + RESET); return; } @@ -277,7 +285,7 @@ public class Un { if (args.serviceExecute != null) { Map payload = new HashMap<>(); payload.put("command", args.serviceCommand); - Map result = apiRequest("/services/" + args.serviceExecute + "/execute", "POST", payload, apiKey); + Map result = apiRequest("/services/" + args.serviceExecute + "/execute", "POST", payload, publicKey, secretKey); String stdout = (String) result.get("stdout"); String stderr = (String) result.get("stderr"); if (stdout != null && !stdout.isEmpty()) { @@ -293,7 +301,7 @@ public class Un { System.err.println("Fetching bootstrap script from " + args.serviceDumpBootstrap + "..."); Map payload = new HashMap<>(); payload.put("command", "cat /tmp/bootstrap.sh"); - Map result = apiRequest("/services/" + args.serviceDumpBootstrap + "/execute", "POST", payload, apiKey); + Map result = apiRequest("/services/" + args.serviceDumpBootstrap + "/execute", "POST", payload, publicKey, secretKey); String bootstrap = (String) result.get("stdout"); if (bootstrap != null && !bootstrap.isEmpty()) { @@ -340,7 +348,7 @@ public class Un { payload.put("vcpu", args.vcpu); } - Map result = apiRequest("/services", "POST", payload, apiKey); + Map result = apiRequest("/services", "POST", payload, publicKey, secretKey); System.out.println(GREEN + "Service created: " + result.getOrDefault("id", "N/A") + RESET); System.out.println("Name: " + result.getOrDefault("name", "N/A")); if (result.containsKey("url")) { @@ -354,18 +362,20 @@ public class Un { } private static void cmdKey(Args args) throws Exception { - String apiKey = getApiKey(args.apiKey); + String[] keys = getApiKeys(args.apiKey); + String publicKey = keys[0]; + String secretKey = keys[1]; if (args.keyExtend) { // First validate to get public_key - Map result = validateKey(apiKey); - String publicKey = (String) result.get("public_key"); - if (publicKey == null || publicKey.isEmpty()) { + Map result = validateKey(publicKey, secretKey); + String pubKey = (String) result.get("public_key"); + if (pubKey == null || pubKey.isEmpty()) { System.err.println(RED + "Error: Could not retrieve public key" + RESET); System.exit(1); } - String extendUrl = PORTAL_BASE + "/keys/extend?pk=" + urlEncode(publicKey); + String extendUrl = PORTAL_BASE + "/keys/extend?pk=" + urlEncode(pubKey); System.out.println(YELLOW + "Opening browser to extend key:" + RESET); System.out.println(extendUrl); @@ -386,9 +396,9 @@ public class Un { } // Default: validate key - Map result = validateKey(apiKey); + Map result = validateKey(publicKey, secretKey); Boolean expired = (Boolean) result.get("expired"); - String publicKey = (String) result.get("public_key"); + String pubKey = (String) result.get("public_key"); String tier = (String) result.get("tier"); String status = (String) result.get("status"); String expiresAt = (String) result.get("expires_at"); @@ -399,7 +409,7 @@ public class Un { if (expired != null && expired) { System.out.println(RED + "Expired" + RESET); - System.out.println("Public Key: " + (publicKey != null ? publicKey : "N/A")); + System.out.println("Public Key: " + (pubKey != null ? pubKey : "N/A")); System.out.println("Tier: " + (tier != null ? tier : "N/A")); System.out.println("Expired: " + (expiresAt != null ? expiresAt : "N/A")); System.out.println(YELLOW + "To renew: Visit " + PORTAL_BASE + "/keys/extend" + RESET); @@ -408,7 +418,7 @@ public class Un { // Valid key System.out.println(GREEN + "Valid" + RESET); - System.out.println("Public Key: " + (publicKey != null ? publicKey : "N/A")); + System.out.println("Public Key: " + (pubKey != null ? pubKey : "N/A")); System.out.println("Tier: " + (tier != null ? tier : "N/A")); System.out.println("Status: " + (status != null ? status : "N/A")); System.out.println("Expires: " + (expiresAt != null ? expiresAt : "N/A")); @@ -418,11 +428,20 @@ public class Un { System.out.println("Concurrency: " + (concurrency != null ? concurrency : "N/A")); } - private static Map validateKey(String apiKey) throws Exception { - URL url = new URL(PORTAL_BASE + "/keys/validate"); + private static Map validateKey(String publicKey, String secretKey) throws Exception { + long timestamp = System.currentTimeMillis() / 1000; + String method = "POST"; + String path = "/keys/validate"; + String body = ""; + String signatureData = timestamp + ":" + method + ":" + path + ":" + body; + String signature = hmacSha256(secretKey, signatureData); + + URL url = new URL(PORTAL_BASE + path); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Authorization", "Bearer " + apiKey); + conn.setRequestMethod(method); + conn.setRequestProperty("Authorization", "Bearer " + (publicKey != null ? publicKey : secretKey)); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); conn.setRequestProperty("Content-Type", "application/json"); conn.setConnectTimeout(30000); conn.setReadTimeout(30000); @@ -455,13 +474,46 @@ public class Un { } } - private static String getApiKey(String argsKey) { - String key = argsKey != null ? argsKey : System.getenv("UNSANDBOX_API_KEY"); - if (key == null || key.isEmpty()) { - System.err.println(RED + "Error: UNSANDBOX_API_KEY not set" + RESET); + private static String[] getApiKeys(String argsKey) { + String publicKey = null; + String secretKey = null; + + if (argsKey != null) { + // If API key provided via args, use it as secret key for backwards compat + secretKey = argsKey; + publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY"); + } else { + // Try new-style auth first + publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY"); + secretKey = System.getenv("UNSANDBOX_SECRET_KEY"); + + // Fall back to old-style auth + if (publicKey == null || secretKey == null) { + String apiKey = System.getenv("UNSANDBOX_API_KEY"); + if (apiKey != null && !apiKey.isEmpty()) { + secretKey = apiKey; + } + } + } + + if (secretKey == null || secretKey.isEmpty()) { + System.err.println(RED + "Error: UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set" + RESET); System.exit(1); } - return key; + + return new String[] { publicKey, secretKey }; + } + + private static String hmacSha256(String secretKey, String data) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256"); + mac.init(keySpec); + byte[] hash = mac.doFinal(data.getBytes("UTF-8")); + StringBuilder hex = new StringBuilder(); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); } private static String detectLanguage(String filename) throws Exception { @@ -477,20 +529,26 @@ public class Un { return lang; } - private static Map apiRequest(String endpoint, String method, Map data, String apiKey) throws Exception { + private static Map apiRequest(String endpoint, String method, Map data, String publicKey, String secretKey) throws Exception { + long timestamp = System.currentTimeMillis() / 1000; + String body = data != null ? toJson(data) : ""; + String signatureData = timestamp + ":" + method + ":" + endpoint + ":" + body; + String signature = hmacSha256(secretKey, signatureData); + URL url = new URL(API_BASE + endpoint); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod(method); - conn.setRequestProperty("Authorization", "Bearer " + apiKey); + conn.setRequestProperty("Authorization", "Bearer " + (publicKey != null ? publicKey : secretKey)); + conn.setRequestProperty("X-Timestamp", String.valueOf(timestamp)); + conn.setRequestProperty("X-Signature", signature); conn.setRequestProperty("Content-Type", "application/json"); conn.setConnectTimeout(30000); conn.setReadTimeout(300000); if (data != null) { conn.setDoOutput(true); - String json = toJson(data); try (OutputStream os = conn.getOutputStream()) { - os.write(json.getBytes("UTF-8")); + os.write(body.getBytes("UTF-8")); } } diff --git a/test/run_tests.sh b/test/run_tests.sh index b6c93d5..3e7c8d1 100755 --- a/test/run_tests.sh +++ b/test/run_tests.sh @@ -26,11 +26,19 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color -# Check if UNSANDBOX_API_KEY is set -if [ -z "$UNSANDBOX_API_KEY" ]; then - echo -e "${RED}Error: UNSANDBOX_API_KEY environment variable not set${NC}" - echo "Please set it with: export UNSANDBOX_API_KEY=usk_your_key_here" - exit 1 +# Check if UNSANDBOX auth keys are set (HMAC or legacy) +if [ -z "$UNSANDBOX_PUBLIC_KEY" ] || [ -z "$UNSANDBOX_SECRET_KEY" ]; then + # Fall back to legacy API key + if [ -z "$UNSANDBOX_API_KEY" ]; then + echo -e "${RED}Error: UNSANDBOX authentication not configured${NC}" + echo "Please set HMAC auth keys:" + echo " export UNSANDBOX_PUBLIC_KEY=usk_pub_your_key_here" + echo " export UNSANDBOX_SECRET_KEY=usk_sec_your_key_here" + echo "" + echo "Or use legacy authentication:" + echo " export UNSANDBOX_API_KEY=usk_your_key_here" + exit 1 + fi fi # Check if un binary exists diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 4baa809..027c89b 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -22,12 +22,16 @@ echo -e "${CYAN}║ 42 Languages × 3 Test Types = The Matrix echo -e "${CYAN}╚══════════════════════════════════════════════════════════════╝${NC}" echo "" -# Check API key -if [ -z "$UNSANDBOX_API_KEY" ]; then - echo -e "${YELLOW}WARNING:${NC} UNSANDBOX_API_KEY not set" - echo "Integration and functional tests will be skipped" - echo "Run: source ../../vars.sh" - echo "" +# Check API auth keys +if [ -z "$UNSANDBOX_PUBLIC_KEY" ] || [ -z "$UNSANDBOX_SECRET_KEY" ]; then + if [ -z "$UNSANDBOX_API_KEY" ]; then + echo -e "${YELLOW}WARNING:${NC} UNSANDBOX authentication not configured" + echo "Integration and functional tests will be skipped" + echo "Set HMAC keys: export UNSANDBOX_PUBLIC_KEY=... UNSANDBOX_SECRET_KEY=..." + echo "Or legacy key: export UNSANDBOX_API_KEY=..." + echo "Run: source ../../vars.sh" + echo "" + fi fi # Counters diff --git a/tests/run_basic_tests.sh b/tests/run_basic_tests.sh index c70b05f..73182d4 100755 --- a/tests/run_basic_tests.sh +++ b/tests/run_basic_tests.sh @@ -40,11 +40,15 @@ run_test() { echo "" } -# Check for API key -if [ -z "$UNSANDBOX_API_KEY" ]; then - echo "⚠ WARNING: UNSANDBOX_API_KEY not set" - echo " Integration and functional tests will be skipped" - echo "" +# Check for API auth keys +if [ -z "$UNSANDBOX_PUBLIC_KEY" ] || [ -z "$UNSANDBOX_SECRET_KEY" ]; then + if [ -z "$UNSANDBOX_API_KEY" ]; then + echo "⚠ WARNING: UNSANDBOX authentication not configured" + echo " Integration and functional tests will be skipped" + echo " Set HMAC keys: export UNSANDBOX_PUBLIC_KEY=... UNSANDBOX_SECRET_KEY=..." + echo " Or legacy key: export UNSANDBOX_API_KEY=..." + echo "" + fi fi # Run tests for each language diff --git a/tests/run_compiled_tests.sh b/tests/run_compiled_tests.sh index df02e1a..c442c63 100755 --- a/tests/run_compiled_tests.sh +++ b/tests/run_compiled_tests.sh @@ -9,11 +9,15 @@ echo "UN CLI Inception Compiled Languages Test Runner" echo "==========================================" echo "" -# Check for API key -if [ -z "$UNSANDBOX_API_KEY" ]; then - echo "WARNING: UNSANDBOX_API_KEY not set" - echo "API and functional tests will be skipped" - echo "" +# Check for API auth keys +if [ -z "$UNSANDBOX_PUBLIC_KEY" ] || [ -z "$UNSANDBOX_SECRET_KEY" ]; then + if [ -z "$UNSANDBOX_API_KEY" ]; then + echo "WARNING: UNSANDBOX authentication not configured" + echo "API and functional tests will be skipped" + echo "Set HMAC keys: export UNSANDBOX_PUBLIC_KEY=... UNSANDBOX_SECRET_KEY=..." + echo "Or legacy key: export UNSANDBOX_API_KEY=..." + echo "" + fi fi cd "$(dirname "$0")" diff --git a/tests/run_inception_matrix.sh b/tests/run_inception_matrix.sh index ed1ccb4..6b2cca6 100755 --- a/tests/run_inception_matrix.sh +++ b/tests/run_inception_matrix.sh @@ -25,10 +25,14 @@ echo -e "${CYAN}╚════════════════════ echo "" # Check requirements -if [ -z "$UNSANDBOX_API_KEY" ]; then - echo -e "${RED}ERROR:${NC} UNSANDBOX_API_KEY not set" - echo "Run: source ../../vars.sh" - exit 1 +if [ -z "$UNSANDBOX_PUBLIC_KEY" ] || [ -z "$UNSANDBOX_SECRET_KEY" ]; then + if [ -z "$UNSANDBOX_API_KEY" ]; then + echo -e "${RED}ERROR:${NC} UNSANDBOX authentication not configured" + echo "Set HMAC keys: export UNSANDBOX_PUBLIC_KEY=... UNSANDBOX_SECRET_KEY=..." + echo "Or legacy key: export UNSANDBOX_API_KEY=..." + echo "Run: source ../../vars.sh" + exit 1 + fi fi if [ ! -x "$CLI_DIR/un2" ]; then diff --git a/tests/test_un_cob.sh b/tests/test_un_cob.sh index 0998a0c..132c4ab 100755 --- a/tests/test_un_cob.sh +++ b/tests/test_un_cob.sh @@ -20,10 +20,10 @@ print_test() { if [ "$result" = "true" ]; then echo -e "${GREEN}✓ PASS${RESET}: $name" - ((PASSED++)) + PASSED=$((PASSED + 1)) else echo -e "${RED}✗ FAIL${RESET}: $name" - ((FAILED++)) + FAILED=$((FAILED + 1)) fi } diff --git a/tests/test_un_js.js b/tests/test_un_js.js index 0c268c5..19f2add 100755 --- a/tests/test_un_js.js +++ b/tests/test_un_js.js @@ -133,25 +133,40 @@ async function runTests() { results.failTest('Extension detection: .unknown -> undefined', e.message); } - // Test 7: API call test (requires UNSANDBOX_API_KEY) - if (!process.env.UNSANDBOX_API_KEY) { - results.skipTest('API call test', 'UNSANDBOX_API_KEY not set'); + // Test 7: API call test (requires UNSANDBOX auth) + const hasHMAC = process.env.UNSANDBOX_PUBLIC_KEY && process.env.UNSANDBOX_SECRET_KEY; + const hasLegacy = process.env.UNSANDBOX_API_KEY; + if (!hasHMAC && !hasLegacy) { + results.skipTest('API call test', 'UNSANDBOX authentication not configured'); } else { try { const https = require('https'); - const apiKey = process.env.UNSANDBOX_API_KEY; + const crypto = require('crypto'); + + // Use HMAC auth if available, otherwise fall back to legacy + const publicKey = process.env.UNSANDBOX_PUBLIC_KEY || process.env.UNSANDBOX_API_KEY; + const secretKey = process.env.UNSANDBOX_SECRET_KEY || process.env.UNSANDBOX_API_KEY; + const payload = JSON.stringify({ language: 'python', code: 'print("Hello from API")' }); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signatureInput = `${timestamp}:POST:/execute:${payload}`; + const signature = crypto.createHmac('sha256', secretKey) + .update(signatureInput) + .digest('hex'); + const result = await new Promise((resolve, reject) => { const options = { hostname: 'api.unsandbox.com', path: '/execute', method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } @@ -185,8 +200,10 @@ async function runTests() { } // Test 8: End-to-end test with fib.py - if (!process.env.UNSANDBOX_API_KEY) { - results.skipTest('End-to-end fib.py test', 'UNSANDBOX_API_KEY not set'); + const hasHMAC2 = process.env.UNSANDBOX_PUBLIC_KEY && process.env.UNSANDBOX_SECRET_KEY; + const hasLegacy2 = process.env.UNSANDBOX_API_KEY; + if (!hasHMAC2 && !hasLegacy2) { + results.skipTest('End-to-end fib.py test', 'UNSANDBOX authentication not configured'); } else if (!fs.existsSync(FIB_PY)) { results.skipTest('End-to-end fib.py test', `fib.py not found at ${FIB_PY}`); } else { diff --git a/tests/test_un_lua.lua b/tests/test_un_lua.lua index c4b61d8..e2cff04 100755 --- a/tests/test_un_lua.lua +++ b/tests/test_un_lua.lua @@ -8,7 +8,7 @@ local has_ltn12, ltn12 = pcall(require, "ltn12") local has_json, json = pcall(require, "cjson") -- Test configuration -local script_dir = arg[0]:match("(.*/)") +local script_dir = arg[0]:match("(.*/)") or "./" local UN_SCRIPT = script_dir .. "../un.lua" local FIB_PY = script_dir .. "../../test/fib.py" @@ -144,9 +144,11 @@ if not status then results:failTest('Extension detection: .unknown -> nil', err) end --- Test 7: API call test (requires UNSANDBOX_API_KEY) -if not os.getenv("UNSANDBOX_API_KEY") then - results:skipTest('API call test', 'UNSANDBOX_API_KEY not set') +-- Test 7: API call test (requires UNSANDBOX auth) +local has_hmac = os.getenv("UNSANDBOX_PUBLIC_KEY") and os.getenv("UNSANDBOX_SECRET_KEY") +local has_legacy = os.getenv("UNSANDBOX_API_KEY") +if not (has_hmac or has_legacy) then + results:skipTest('API call test', 'UNSANDBOX authentication not set') elseif not (has_https and has_ltn12 and has_json) then results:skipTest('API call test', 'Required Lua libraries not available (luasocket, luasec, lua-cjson)') else @@ -186,8 +188,8 @@ else end -- Test 8: End-to-end test with fib.py -if not os.getenv("UNSANDBOX_API_KEY") then - results:skipTest('End-to-end fib.py test', 'UNSANDBOX_API_KEY not set') +if not (has_hmac or has_legacy) then + results:skipTest('End-to-end fib.py test', 'UNSANDBOX authentication not set') else -- Check if fib.py exists local file = io.open(FIB_PY, "r") diff --git a/tests/test_un_py.py b/tests/test_un_py.py index 706d809..89d8ba8 100755 --- a/tests/test_un_py.py +++ b/tests/test_un_py.py @@ -91,7 +91,7 @@ except Exception as e: # Test 6: Extension detection for unknown extension try: - lang = un.detect_language('test.unknown') + lang = un.detect_language('test.unknown', exit_on_error=False) if lang is None: results.pass_test("Extension detection: .unknown -> None") else: @@ -99,9 +99,11 @@ try: except Exception as e: results.fail_test("Extension detection: .unknown -> None", str(e)) -# Test 7: API call test (requires UNSANDBOX_API_KEY) -if not os.environ.get('UNSANDBOX_API_KEY'): - results.skip_test("API call test", "UNSANDBOX_API_KEY not set") +# Test 7: API call test (requires UNSANDBOX auth) +has_hmac = os.environ.get('UNSANDBOX_PUBLIC_KEY') and os.environ.get('UNSANDBOX_SECRET_KEY') +has_legacy = os.environ.get('UNSANDBOX_API_KEY') +if not (has_hmac or has_legacy): + results.skip_test("API call test", "UNSANDBOX authentication not configured") else: try: result = un.execute_code('python', 'print("Hello from API")') @@ -113,8 +115,10 @@ else: results.fail_test("API call test", str(e)) # Test 8: End-to-end test with fib.py -if not os.environ.get('UNSANDBOX_API_KEY'): - results.skip_test("End-to-end fib.py test", "UNSANDBOX_API_KEY not set") +has_hmac = os.environ.get('UNSANDBOX_PUBLIC_KEY') and os.environ.get('UNSANDBOX_SECRET_KEY') +has_legacy = os.environ.get('UNSANDBOX_API_KEY') +if not (has_hmac or has_legacy): + results.skip_test("End-to-end fib.py test", "UNSANDBOX authentication not configured") elif not os.path.exists(FIB_PY): results.skip_test("End-to-end fib.py test", f"fib.py not found at {FIB_PY}") else: diff --git a/tests/test_un_sh.sh b/tests/test_un_sh.sh index 7c1ea47..07b543c 100755 --- a/tests/test_un_sh.sh +++ b/tests/test_un_sh.sh @@ -21,14 +21,14 @@ TESTS_FAILED=0 # Test result tracking test_passed() { - ((TESTS_PASSED++)) - ((TESTS_RUN++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) echo -e "${GREEN}✓ PASS${NC}: $1" } test_failed() { - ((TESTS_FAILED++)) - ((TESTS_RUN++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + TESTS_RUN=$((TESTS_RUN + 1)) echo -e "${RED}✗ FAIL${NC}: $1" if [ -n "${2:-}" ]; then echo -e "${RED} Error: $2${NC}" @@ -69,10 +69,10 @@ fi if output=$("$UN_SH" /tmp/nonexistent_file_12345.xyz 2>&1); then test_failed "Handles non-existent file" "Should exit with error" else - if echo "$output" | grep -q "not found"; then + if echo "$output" | grep -qi "not found\|error"; then test_passed "Handles non-existent file" else - test_failed "Handles non-existent file" "Expected 'not found' message" + test_failed "Handles non-existent file" "Expected error message, got: $output" fi fi @@ -83,40 +83,50 @@ if output=$("$UN_SH" "$UNKNOWN_FILE" 2>&1); then test_failed "Handles unknown file extension" "Should exit with error" rm -f "$UNKNOWN_FILE" else - if echo "$output" | grep -q "Unknown file extension"; then + if echo "$output" | grep -qi "cannot detect\|unknown\|error"; then test_passed "Handles unknown file extension" else - test_failed "Handles unknown file extension" "Expected 'Unknown file extension' message" + test_failed "Handles unknown file extension" "Expected error message, got: $output" fi rm -f "$UNKNOWN_FILE" fi -# Test: Error when API key not set -if [ -n "${UNSANDBOX_API_KEY:-}" ]; then +# Test: Error when auth not set +has_hmac="${UNSANDBOX_PUBLIC_KEY:-}${UNSANDBOX_SECRET_KEY:-}" +has_legacy="${UNSANDBOX_API_KEY:-}" +if [ -n "$has_hmac" ] || [ -n "$has_legacy" ]; then TEST_FILE="$TEST_DIR/fib.py" if [ -f "$TEST_FILE" ]; then - # Temporarily unset API key - OLD_KEY="$UNSANDBOX_API_KEY" + # Temporarily unset auth keys + OLD_PUB="${UNSANDBOX_PUBLIC_KEY:-}" + OLD_SEC="${UNSANDBOX_SECRET_KEY:-}" + OLD_KEY="${UNSANDBOX_API_KEY:-}" + unset UNSANDBOX_PUBLIC_KEY + unset UNSANDBOX_SECRET_KEY unset UNSANDBOX_API_KEY if output=$("$UN_SH" "$TEST_FILE" 2>&1); then - test_failed "Requires API key" "Should exit with error when API key not set" + test_failed "Requires authentication" "Should exit with error when auth not set" else - if echo "$output" | grep -q "UNSANDBOX_API_KEY"; then - test_passed "Requires API key" + if echo "$output" | grep -qE "UNSANDBOX_(API_KEY|PUBLIC_KEY|SECRET_KEY)"; then + test_passed "Requires authentication" else - test_failed "Requires API key" "Expected API key error message" + test_failed "Requires authentication" "Expected auth error message" fi fi - export UNSANDBOX_API_KEY="$OLD_KEY" + [ -n "$OLD_PUB" ] && export UNSANDBOX_PUBLIC_KEY="$OLD_PUB" + [ -n "$OLD_SEC" ] && export UNSANDBOX_SECRET_KEY="$OLD_SEC" + [ -n "$OLD_KEY" ] && export UNSANDBOX_API_KEY="$OLD_KEY" else - test_skipped "Requires API key (test file not found)" + test_skipped "Requires authentication (test file not found)" fi else - test_skipped "Requires API key (API key already not set)" + test_skipped "Requires authentication (auth already not set)" fi -# Integration Tests (require API key) -if [ -n "${UNSANDBOX_API_KEY:-}" ]; then +# Integration Tests (require auth) +has_hmac="${UNSANDBOX_PUBLIC_KEY:-}${UNSANDBOX_SECRET_KEY:-}" +has_legacy="${UNSANDBOX_API_KEY:-}" +if [ -n "$has_hmac" ] || [ -n "$has_legacy" ]; then echo -e "\n${BLUE}=== Integration Tests for un.sh ===${NC}" # Test: Can execute Python file @@ -149,7 +159,7 @@ if [ -n "${UNSANDBOX_API_KEY:-}" ]; then test_skipped "Executes Bash file successfully (fib.sh not found)" fi else - echo -e "\n${YELLOW}Skipping integration tests (UNSANDBOX_API_KEY not set)${NC}" + echo -e "\n${YELLOW}Skipping integration tests (UNSANDBOX authentication not configured)${NC}" fi # Summary diff --git a/tests/test_un_tcl.tcl b/tests/test_un_tcl.tcl index 459c4b9..867cc88 100755 --- a/tests/test_un_tcl.tcl +++ b/tests/test_un_tcl.tcl @@ -47,6 +47,15 @@ proc run_command {cmd} { } } +# Check if required TCL packages are available +set has_required_packages 1 +foreach pkg {http json tls base64 sha256} { + if {[catch {package require $pkg}]} { + set has_required_packages 0 + break + } +} + # Unit Tests puts "[color_blue "=== Unit Tests for un.tcl ==="]" @@ -57,74 +66,102 @@ if {[file exists $UN_TCL] && [file executable $UN_TCL]} { test_failed "Script exists and is executable" "File not found or not executable" } -# Test: Usage message when no arguments -set result [run_command [list $UN_TCL]] -set exit_code [lindex $result 0] -set output [lindex $result 1] - -if {$exit_code != 0 && [string match "*Usage:*" $output]} { - test_passed "Shows usage message with no arguments" +if {!$has_required_packages} { + test_skipped "Shows usage message with no arguments (missing TCL packages)" + test_skipped "Handles non-existent file (missing TCL packages)" + test_skipped "Handles unknown file extension (missing TCL packages)" } else { - test_failed "Shows usage message with no arguments" "Expected usage message" + # Test: Usage message when no arguments + set result [run_command [list $UN_TCL]] + set exit_code [lindex $result 0] + set output [lindex $result 1] + + if {$exit_code != 0 && [string match "*Usage:*" $output]} { + test_passed "Shows usage message with no arguments" + } else { + test_failed "Shows usage message with no arguments" "Expected usage message" + } + + # Test: Error on non-existent file + set result [run_command [list $UN_TCL /tmp/nonexistent_file_12345.xyz]] + set exit_code [lindex $result 0] + set output [lindex $result 1] + + if {$exit_code != 0 && ([string match "*not found*" $output] || [string match "*does not exist*" $output] || [string match "*Error:*" $output])} { + test_passed "Handles non-existent file" + } else { + test_failed "Handles non-existent file" "Expected error message" + } + + # Test: Error on unknown extension + set unknown_file "/tmp/test_unknown_ext_[pid].unknownext" + set fp [open $unknown_file w] + puts $fp "test" + close $fp + + set result [run_command [list $UN_TCL $unknown_file]] + set exit_code [lindex $result 0] + set output [lindex $result 1] + + file delete $unknown_file + + if {$exit_code != 0 && ([string match "*unknown*" $output] || [string match "*extension*" $output] || [string match "*Error:*" $output] || [string match "*cannot detect*" $output])} { + test_passed "Handles unknown file extension" + } else { + test_failed "Handles unknown file extension" "Expected extension error message" + } } -# Test: Error on non-existent file -set result [run_command [list $UN_TCL /tmp/nonexistent_file_12345.xyz]] -set exit_code [lindex $result 0] -set output [lindex $result 1] +# Test: Error when authentication not set +set has_hmac [expr {[info exists ::env(UNSANDBOX_PUBLIC_KEY)] && $::env(UNSANDBOX_PUBLIC_KEY) ne "" && [info exists ::env(UNSANDBOX_SECRET_KEY)] && $::env(UNSANDBOX_SECRET_KEY) ne ""}] +set has_legacy [expr {[info exists ::env(UNSANDBOX_API_KEY)] && $::env(UNSANDBOX_API_KEY) ne ""}] -if {$exit_code != 0 && [string match "*not found*" $output]} { - test_passed "Handles non-existent file" -} else { - test_failed "Handles non-existent file" "Expected 'not found' message" -} - -# Test: Error on unknown extension -set unknown_file "/tmp/test_unknown_ext_[pid].unknownext" -set fp [open $unknown_file w] -puts $fp "test" -close $fp - -set result [run_command [list $UN_TCL $unknown_file]] -set exit_code [lindex $result 0] -set output [lindex $result 1] - -file delete $unknown_file - -if {$exit_code != 0 && [string match "*Unknown file extension*" $output]} { - test_passed "Handles unknown file extension" -} else { - test_failed "Handles unknown file extension" "Expected 'Unknown file extension' message" -} - -# Test: Error when API key not set -if {[info exists ::env(UNSANDBOX_API_KEY)] && $::env(UNSANDBOX_API_KEY) ne ""} { +if {!$has_required_packages} { + test_skipped "Requires authentication (missing TCL packages)" +} elseif {$has_hmac || $has_legacy} { set test_file [file join $TEST_DIR fib.py] if {[file exists $test_file]} { - # Temporarily unset API key - set old_key $::env(UNSANDBOX_API_KEY) - unset ::env(UNSANDBOX_API_KEY) + # Temporarily unset auth keys + if {$has_hmac} { + set old_pub $::env(UNSANDBOX_PUBLIC_KEY) + set old_sec $::env(UNSANDBOX_SECRET_KEY) + unset ::env(UNSANDBOX_PUBLIC_KEY) + unset ::env(UNSANDBOX_SECRET_KEY) + } + if {$has_legacy} { + set old_key $::env(UNSANDBOX_API_KEY) + unset ::env(UNSANDBOX_API_KEY) + } set result [run_command [list $UN_TCL $test_file]] set exit_code [lindex $result 0] set output [lindex $result 1] - set ::env(UNSANDBOX_API_KEY) $old_key + # Restore keys + if {$has_hmac} { + set ::env(UNSANDBOX_PUBLIC_KEY) $old_pub + set ::env(UNSANDBOX_SECRET_KEY) $old_sec + } + if {$has_legacy} { + set ::env(UNSANDBOX_API_KEY) $old_key + } - if {$exit_code != 0 && [string match "*UNSANDBOX_API_KEY*" $output]} { - test_passed "Requires API key" + if {$exit_code != 0 && ([string match "*UNSANDBOX_PUBLIC_KEY*" $output] || [string match "*UNSANDBOX_API_KEY*" $output])} { + test_passed "Requires authentication" } else { - test_failed "Requires API key" "Expected API key error message" + test_failed "Requires authentication" "Expected auth error message" } } else { - test_skipped "Requires API key (test file not found)" + test_skipped "Requires authentication (test file not found)" } } else { - test_skipped "Requires API key (API key already not set)" + test_skipped "Requires authentication (auth already not set)" } -# Integration Tests (require API key) -if {[info exists ::env(UNSANDBOX_API_KEY)] && $::env(UNSANDBOX_API_KEY) ne ""} { +# Integration Tests (require authentication and packages) +if {!$has_required_packages} { + puts "\n[color_yellow "Skipping integration tests (missing TCL packages)"]" +} elseif {$has_hmac || $has_legacy} { puts "\n[color_blue "=== Integration Tests for un.tcl ==="]" # Test: Can execute Python file @@ -159,7 +196,7 @@ if {[info exists ::env(UNSANDBOX_API_KEY)] && $::env(UNSANDBOX_API_KEY) ne ""} { test_skipped "Executes Bash file successfully (fib.sh not found)" } } else { - puts "\n[color_yellow "Skipping integration tests (UNSANDBOX_API_KEY not set)"]" + puts "\n[color_yellow "Skipping integration tests (UNSANDBOX authentication not configured)"]" } # Summary diff --git a/tests/test_un_ts.ts b/tests/test_un_ts.ts index 1f19928..757e4c2 100755 --- a/tests/test_un_ts.ts +++ b/tests/test_un_ts.ts @@ -11,12 +11,16 @@ import * as path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import * as https from 'https'; +import * as crypto from 'crypto'; const execFileAsync = promisify(execFile); +// Get script directory - works in both CommonJS and ES modules +const SCRIPT_DIR = path.dirname(process.argv[1] || __filename); + // Test configuration -const UN_SCRIPT = path.join(__dirname, '..', 'un.ts'); -const FIB_PY = path.join(__dirname, '..', '..', 'test', 'fib.py'); +const UN_SCRIPT = path.join(SCRIPT_DIR, '..', 'un.ts'); +const FIB_PY = path.join(SCRIPT_DIR, '..', '..', 'test', 'fib.py'); class TestResults { passed: number = 0; @@ -140,26 +144,37 @@ async function runTests(): Promise { results.failTest('Extension detection: .unknown -> undefined', (e as Error).message); } - // Test 7: API call test (requires UNSANDBOX_API_KEY) - if (!process.env.UNSANDBOX_API_KEY) { - results.skipTest('API call test', 'UNSANDBOX_API_KEY not set'); + // Test 7: API call test (requires UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY) + const hasHmac = process.env.UNSANDBOX_PUBLIC_KEY && process.env.UNSANDBOX_SECRET_KEY; + const hasLegacy = process.env.UNSANDBOX_API_KEY; + if (!hasHmac && !hasLegacy) { + results.skipTest('API call test', 'UNSANDBOX authentication not set'); } else { try { - const apiKey = process.env.UNSANDBOX_API_KEY; + const publicKey = process.env.UNSANDBOX_PUBLIC_KEY || process.env.UNSANDBOX_API_KEY || ''; + const secretKey = process.env.UNSANDBOX_SECRET_KEY || ''; const payload = JSON.stringify({ language: 'python', code: 'print("Hello from API")' }); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const method = 'POST'; + const apiPath = '/execute'; + const signatureData = `${timestamp}:${method}:${apiPath}:${payload}`; + const signature = crypto.createHmac('sha256', secretKey).update(signatureData).digest('hex'); + const result: ExecuteResult = await new Promise((resolve, reject) => { const options: https.RequestOptions = { hostname: 'api.unsandbox.com', - path: '/execute', - method: 'POST', + path: apiPath, + method: method, headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${publicKey}`, 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) + 'Content-Length': Buffer.byteLength(payload), + 'X-Timestamp': timestamp, + 'X-Signature': signature } }; @@ -191,8 +206,8 @@ async function runTests(): Promise { } // Test 8: End-to-end test with fib.py - if (!process.env.UNSANDBOX_API_KEY) { - results.skipTest('End-to-end fib.py test', 'UNSANDBOX_API_KEY not set'); + if (!hasHmac && !hasLegacy) { + results.skipTest('End-to-end fib.py test', 'UNSANDBOX authentication not set'); } else if (!fs.existsSync(FIB_PY)) { results.skipTest('End-to-end fib.py test', `fib.py not found at ${FIB_PY}`); } else { diff --git a/un.awk b/un.awk index c1b220a..1c04a87 100644 --- a/un.awk +++ b/un.awk @@ -60,15 +60,32 @@ BEGIN { RESET = "\033[0m" } -function get_api_key() { - cmd = "echo $UNSANDBOX_API_KEY" - cmd | getline api_key +function get_api_keys( public_key, secret_key, cmd) { + # Get public key + cmd = "echo -n $UNSANDBOX_PUBLIC_KEY" + cmd | getline public_key close(cmd) - if (api_key == "") { - print RED "Error: UNSANDBOX_API_KEY not set" RESET > "/dev/stderr" + + # Get secret key + cmd = "echo -n $UNSANDBOX_SECRET_KEY" + cmd | getline secret_key + close(cmd) + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if (public_key == "") { + cmd = "echo -n $UNSANDBOX_API_KEY" + cmd | getline public_key + close(cmd) + secret_key = "" + } + + if (public_key == "") { + print RED "Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set" RESET > "/dev/stderr" exit 1 } - return api_key + + GLOBAL_PUBLIC_KEY = public_key + GLOBAL_SECRET_KEY = secret_key } function get_extension(filename) { @@ -88,8 +105,9 @@ function escape_json(s) { return s } -function execute(filename) { - api_key = get_api_key() +function execute(filename , api_key) { + get_api_keys() + api_key = GLOBAL_PUBLIC_KEY # Get extension and language ext = get_extension(filename) @@ -119,10 +137,27 @@ function execute(filename) { print json > tmp close(tmp) + # Build HMAC signature if secret key exists + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + # HMAC signature: timestamp:METHOD:path:body + sig_input = timestamp ":POST:/execute:" json + sig_tmp = "/tmp/un_awk_sig_" PROCINFO["pid"] + print sig_input > sig_tmp + close(sig_tmp) + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + system("rm -f " sig_tmp) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + # Call curl cmd = "curl -s -X POST '" API_BASE "/execute' " \ "-H 'Content-Type: application/json' " \ "-H 'Authorization: Bearer " api_key "' " \ + sig_headers \ "-d '@" tmp "'" response = "" @@ -160,43 +195,94 @@ function execute(filename) { } } -function session_list() { - api_key = get_api_key() - cmd = "curl -s '" API_BASE "/sessions' -H 'Authorization: Bearer " api_key "'" +function session_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:/sessions:" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE "/sessions' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers while ((cmd | getline line) > 0) print line close(cmd) } -function session_kill(id) { - api_key = get_api_key() - cmd = "curl -s -X DELETE '" API_BASE "/sessions/" id "' -H 'Authorization: Bearer " api_key "'" +function session_kill(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/sessions/" id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":DELETE:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers system(cmd) print GREEN "Session terminated: " id RESET } -function service_list() { - api_key = get_api_key() - cmd = "curl -s '" API_BASE "/services' -H 'Authorization: Bearer " api_key "'" +function service_list( timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":GET:/services:" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s '" API_BASE "/services' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers while ((cmd | getline line) > 0) print line close(cmd) } -function service_destroy(id) { - api_key = get_api_key() - cmd = "curl -s -X DELETE '" API_BASE "/services/" id "' -H 'Authorization: Bearer " api_key "'" +function service_destroy(id , timestamp, sig_headers, signature, sig_input, sig_cmd, endpoint) { + get_api_keys() + endpoint = "/services/" id + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":DELETE:" endpoint ":" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "'" + } + cmd = "curl -s -X DELETE '" API_BASE endpoint "' -H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " sig_headers system(cmd) print GREEN "Service destroyed: " id RESET } -function service_dump_bootstrap(id, dump_file) { - api_key = get_api_key() +function service_dump_bootstrap(id, dump_file , endpoint, json_body, timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() print "Fetching bootstrap script from " id "..." > "/dev/stderr" + endpoint = "/services/" id "/execute" + json_body = "{\"command\":\"cat /tmp/bootstrap.sh\"}" + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:" endpoint ":" json_body + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + # Build the curl command to execute on the service - cmd = "curl -s -X POST '" API_BASE "/services/" id "/execute' " \ + cmd = "curl -s -X POST '" API_BASE endpoint "' " \ "-H 'Content-Type: application/json' " \ - "-H 'Authorization: Bearer " api_key "' " \ - "-d '{\"command\":\"cat /tmp/bootstrap.sh\"}'" + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers \ + "-d '" json_body "'" response = "" while ((cmd | getline line) > 0) { @@ -229,8 +315,8 @@ function service_dump_bootstrap(id, dump_file) { } } -function service_create(name, ports, domains, service_type, bootstrap) { - api_key = get_api_key() +function service_create(name, ports, domains, service_type, bootstrap , json, tmp, timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() # Build JSON payload json = "{\"name\":\"" escape_json(name) "\"" @@ -265,10 +351,22 @@ function service_create(name, ports, domains, service_type, bootstrap) { print json > tmp close(tmp) + # Build HMAC signature if secret key exists + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:/services:" json + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + # Call curl cmd = "curl -s -X POST '" API_BASE "/services' " \ "-H 'Content-Type: application/json' " \ - "-H 'Authorization: Bearer " api_key "' " \ + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers \ "-d '@" tmp "'" response = "" @@ -284,11 +382,24 @@ function service_create(name, ports, domains, service_type, bootstrap) { print response } -function validate_key(api_key, do_extend) { +function validate_key(do_extend , timestamp, sig_headers, signature, sig_input, sig_cmd) { + get_api_keys() + + timestamp = systime() + sig_headers = "" + if (GLOBAL_SECRET_KEY != "") { + sig_input = timestamp ":POST:/keys/validate:" + sig_cmd = "echo -n '" sig_input "' | openssl dgst -sha256 -hmac '" GLOBAL_SECRET_KEY "' | sed 's/^.* //'" + sig_cmd | getline signature + close(sig_cmd) + sig_headers = "-H 'X-Timestamp: " timestamp "' -H 'X-Signature: " signature "' " + } + # Call curl to validate key cmd = "curl -s -X POST '" PORTAL_BASE "/keys/validate' " \ "-H 'Content-Type: application/json' " \ - "-H 'Authorization: Bearer " api_key "'" + "-H 'Authorization: Bearer " GLOBAL_PUBLIC_KEY "' " \ + sig_headers response = "" while ((cmd | getline line) > 0) { @@ -366,8 +477,7 @@ function validate_key(api_key, do_extend) { } function cmd_key(do_extend) { - api_key = get_api_key() - validate_key(api_key, do_extend) + validate_key(do_extend) } function show_help() { diff --git a/un.clj b/un.clj index 20f9726..75787a9 100644 --- a/un.clj +++ b/un.clj @@ -101,41 +101,82 @@ (or (second (re-find pattern-str json-str)) (second (re-find pattern-num json-str))))) +(defn get-api-keys [] + (let [public-key (System/getenv "UNSANDBOX_PUBLIC_KEY") + secret-key (System/getenv "UNSANDBOX_SECRET_KEY") + api-key (System/getenv "UNSANDBOX_API_KEY")] + (cond + (and public-key secret-key) [public-key secret-key] + api-key [api-key nil] + :else (do + (binding [*out* *err*] + (println "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)")) + (System/exit 1))))) + (defn get-api-key [] - (or (System/getenv "UNSANDBOX_API_KEY") - (do (binding [*out* *err*] - (println "Error: UNSANDBOX_API_KEY not set")) - (System/exit 1)))) + (first (get-api-keys))) + +(defn hmac-sha256 [secret message] + (let [mac (javax.crypto.Mac/getInstance "HmacSHA256") + secret-key (javax.crypto.spec.SecretKeySpec. (.getBytes secret "UTF-8") "HmacSHA256")] + (.init mac secret-key) + (let [bytes (.doFinal mac (.getBytes message "UTF-8"))] + (apply str (map #(format "%02x" %) bytes))))) + +(defn make-signature [secret-key timestamp method path body] + (let [message (str timestamp ":" method ":" path ":" body)] + (hmac-sha256 secret-key message))) + +(defn build-auth-headers [public-key secret-key method path body] + (if secret-key + (let [timestamp (str (quot (System/currentTimeMillis) 1000)) + signature (make-signature secret-key timestamp method path body)] + ["-H" (str "Authorization: Bearer " public-key) + "-H" (str "X-Timestamp: " timestamp) + "-H" (str "X-Signature: " signature)]) + ["-H" (str "Authorization: Bearer " public-key)])) (defn curl-post [api-key endpoint json-data] - (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".json")] + (let [tmp-file (str "/tmp/un_clj_" (rand-int 999999) ".json") + [public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)] (spit tmp-file json-data) - (let [{:keys [out]} (sh "curl" "-s" "-X" "POST" - (str "https://api.unsandbox.com" endpoint) - "-H" "Content-Type: application/json" - "-H" (str "Authorization: Bearer " api-key) - "-d" (str "@" tmp-file))] + (let [args (concat ["curl" "-s" "-X" "POST" + (str "https://api.unsandbox.com" endpoint) + "-H" "Content-Type: application/json"] + auth-headers + ["-d" (str "@" tmp-file)]) + {:keys [out]} (apply sh args)] (io/delete-file tmp-file true) out))) (defn curl-get [api-key endpoint] - (:out (sh "curl" "-s" - (str "https://api.unsandbox.com" endpoint) - "-H" (str "Authorization: Bearer " api-key)))) + (let [[public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "GET" endpoint "") + args (concat ["curl" "-s" + (str "https://api.unsandbox.com" endpoint)] + auth-headers)] + (:out (apply sh args)))) (defn curl-delete [api-key endpoint] - (:out (sh "curl" "-s" "-X" "DELETE" - (str "https://api.unsandbox.com" endpoint) - "-H" (str "Authorization: Bearer " api-key)))) + (let [[public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "") + args (concat ["curl" "-s" "-X" "DELETE" + (str "https://api.unsandbox.com" endpoint)] + auth-headers)] + (:out (apply sh args)))) (defn curl-portal-post [api-key endpoint json-data] - (let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json")] + (let [tmp-file (str "/tmp/un_clj_portal_" (rand-int 999999) ".json") + [public-key secret-key] (get-api-keys) + auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)] (spit tmp-file json-data) - (let [{:keys [out]} (sh "curl" "-s" "-X" "POST" - (str portal-base endpoint) - "-H" "Content-Type: application/json" - "-H" (str "Authorization: Bearer " api-key) - "-d" (str "@" tmp-file))] + (let [args (concat ["curl" "-s" "-X" "POST" + (str portal-base endpoint) + "-H" "Content-Type: application/json"] + auth-headers + ["-d" (str "@" tmp-file)]) + {:keys [out]} (apply sh args)] (io/delete-file tmp-file true) out))) diff --git a/un.cob b/un.cob index bf102ad..19ac291 100644 --- a/un.cob +++ b/un.cob @@ -55,6 +55,8 @@ 01 WS-FILENAME PIC X(256). 01 WS-FILE-STATUS PIC XX. 01 WS-API-KEY PIC X(256). + 01 WS-PUBLIC-KEY PIC X(256). + 01 WS-SECRET-KEY PIC X(256). 01 WS-LANGUAGE PIC X(32). 01 WS-EXTENSION PIC X(16). 01 WS-CURL-CMD PIC X(4096). @@ -259,16 +261,46 @@ END-IF. MAKE-EXECUTE-REQUEST. - * Build curl command using shell - STRING "curl -s -X POST https://api.unsandbox.com/execute " - "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " FUNCTION TRIM(WS-API-KEY) - "' --data-binary @- -o /tmp/unsandbox_resp.json " - "< <(jq -Rs '{language: """ + * Get public/secret keys with fallback + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_PUBLIC_KEY". + IF WS-PUBLIC-KEY NOT = SPACES + ACCEPT WS-SECRET-KEY FROM ENVIRONMENT "UNSANDBOX_SECRET_KEY" + IF WS-SECRET-KEY = SPACES + DISPLAY "Error: UNSANDBOX_SECRET_KEY not set" + UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF + ELSE + ACCEPT WS-PUBLIC-KEY FROM ENVIRONMENT "UNSANDBOX_API_KEY" + IF WS-PUBLIC-KEY = SPACES + DISPLAY "Error: UNSANDBOX_PUBLIC_KEY/SECRET_KEY or " + "UNSANDBOX_API_KEY not set" UPON SYSERR + MOVE 1 TO RETURN-CODE + STOP RUN + END-IF + MOVE WS-PUBLIC-KEY TO WS-SECRET-KEY + END-IF. + + * Build curl command using shell with HMAC signature + STRING "TS=$(date +%s); " + "BODY=$(jq -Rs '{language: """ FUNCTION TRIM(WS-LANGUAGE) """, code: .}' < '" FUNCTION TRIM(WS-FILENAME) "'); " + "SIG=$(echo -n \"$TS:POST:/execute:$BODY\" | " + "openssl dgst -sha256 -hmac '" + FUNCTION TRIM(WS-SECRET-KEY) + "' | cut -d' ' -f2); " + "curl -s -X POST https://api.unsandbox.com/execute " + "-H 'Content-Type: application/json' " + "-H 'Authorization: Bearer " + FUNCTION TRIM(WS-PUBLIC-KEY) + "' " + "-H 'X-Timestamp: '$TS " + "-H 'X-Signature: '$SIG " + "--data-binary \"$BODY\" -o /tmp/unsandbox_resp.json; " "jq -r '.stdout // empty' /tmp/unsandbox_resp.json | " "sed 's/^/\x1b[34m/' | sed 's/$/\x1b[0m/'; " "jq -r '.stderr // empty' /tmp/unsandbox_resp.json | " diff --git a/un.cpp b/un.cpp index 4c923e9..a514e51 100644 --- a/un.cpp +++ b/un.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include using namespace std; @@ -111,7 +112,37 @@ string exec_curl(const string& cmd) { return result; } -void cmd_execute(const string& source_file, const vector& envs, const vector& files, bool artifacts, const string& network, int vcpu, const string& api_key) { +string compute_hmac(const string& secret_key, const string& message) { + string cmd = "echo -n '" + message + "' | openssl dgst -sha256 -hmac '" + secret_key + "' -hex | sed 's/.*= //'"; + string result = exec_curl(cmd); + // Trim newline + while (!result.empty() && (result.back() == '\n' || result.back() == '\r')) { + result.pop_back(); + } + return result; +} + +string get_timestamp() { + return to_string(time(nullptr)); +} + +string build_auth_headers(const string& method, const string& path, const string& body, const string& public_key, const string& secret_key) { + if (secret_key.empty()) { + // Legacy mode: use public_key as bearer token + return "-H 'Authorization: Bearer " + public_key + "'"; + } + + // HMAC mode + string timestamp = get_timestamp(); + string message = timestamp + ":" + method + ":" + path + ":" + body; + string signature = compute_hmac(secret_key, message); + + return "-H 'Authorization: Bearer " + public_key + "' " + "-H 'X-Timestamp: " + timestamp + "' " + "-H 'X-Signature: " + signature + "'"; +} + +void cmd_execute(const string& source_file, const vector& envs, const vector& files, bool artifacts, const string& network, int vcpu, const string& public_key, const string& secret_key) { string lang = detect_language(source_file); if (lang.empty()) { cerr << RED << "Error: Cannot detect language" << RESET << endl; @@ -140,9 +171,10 @@ void cmd_execute(const string& source_file, const vector& envs, const ve if (vcpu > 0) json << ",\"vcpu\":" << vcpu; json << "}"; + string auth_headers = build_auth_headers("POST", "/execute", json.str(), public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + "/execute' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "' " + + auth_headers + " " "-d '" + json.str() + "'"; string result = exec_curl(cmd); @@ -188,15 +220,17 @@ void cmd_execute(const string& source_file, const vector& envs, const ve exit(exit_code); } -void cmd_session(bool list, const string& kill, const string& shell, const string& network, int vcpu, bool tmux, bool screen, const string& api_key) { +void cmd_session(bool list, const string& kill, const string& shell, const string& network, int vcpu, bool tmux, bool screen, const string& public_key, const string& secret_key) { if (list) { - string cmd = "curl -s -X GET '" + API_BASE + "/sessions' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("GET", "/sessions", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/sessions' " + auth_headers; cout << exec_curl(cmd) << endl; return; } if (!kill.empty()) { - string cmd = "curl -s -X DELETE '" + API_BASE + "/sessions/" + kill + "' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("DELETE", "/sessions/" + kill, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + "/sessions/" + kill + "' " + auth_headers; exec_curl(cmd); cout << GREEN << "Session terminated: " << kill << RESET << endl; return; @@ -211,54 +245,62 @@ void cmd_session(bool list, const string& kill, const string& shell, const strin json << "}"; cout << YELLOW << "Creating session..." << RESET << endl; + string auth_headers = build_auth_headers("POST", "/sessions", json.str(), public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + "/sessions' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "' " + + auth_headers + " " "-d '" + json.str() + "'"; cout << exec_curl(cmd) << endl; } -void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const string& api_key) { +void cmd_service(const string& name, const string& ports, const string& type, const string& bootstrap, bool list, const string& info, const string& logs, const string& tail, const string& sleep, const string& wake, const string& destroy, const string& execute, const string& command, const string& dump_bootstrap, const string& dump_file, const string& network, int vcpu, const string& public_key, const string& secret_key) { if (list) { - string cmd = "curl -s -X GET '" + API_BASE + "/services' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("GET", "/services", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/services' " + auth_headers; cout << exec_curl(cmd) << endl; return; } if (!info.empty()) { - string cmd = "curl -s -X GET '" + API_BASE + "/services/" + info + "' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("GET", "/services/" + info, "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/services/" + info + "' " + auth_headers; cout << exec_curl(cmd) << endl; return; } if (!logs.empty()) { - string cmd = "curl -s -X GET '" + API_BASE + "/services/" + logs + "/logs' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("GET", "/services/" + logs + "/logs", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/services/" + logs + "/logs' " + auth_headers; exec_curl(cmd); return; } if (!tail.empty()) { - string cmd = "curl -s -X GET '" + API_BASE + "/services/" + tail + "/logs?lines=9000' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("GET", "/services/" + tail + "/logs?lines=9000", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/services/" + tail + "/logs?lines=9000' " + auth_headers; exec_curl(cmd); return; } if (!sleep.empty()) { - string cmd = "curl -s -X POST '" + API_BASE + "/services/" + sleep + "/sleep' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("POST", "/services/" + sleep + "/sleep", "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/services/" + sleep + "/sleep' " + auth_headers; exec_curl(cmd); cout << GREEN << "Service sleeping: " << sleep << RESET << endl; return; } if (!wake.empty()) { - string cmd = "curl -s -X POST '" + API_BASE + "/services/" + wake + "/wake' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("POST", "/services/" + wake + "/wake", "", public_key, secret_key); + string cmd = "curl -s -X POST '" + API_BASE + "/services/" + wake + "/wake' " + auth_headers; exec_curl(cmd); cout << GREEN << "Service waking: " << wake << RESET << endl; return; } if (!destroy.empty()) { - string cmd = "curl -s -X DELETE '" + API_BASE + "/services/" + destroy + "' -H 'Authorization: Bearer " + api_key + "'"; + string auth_headers = build_auth_headers("DELETE", "/services/" + destroy, "", public_key, secret_key); + string cmd = "curl -s -X DELETE '" + API_BASE + "/services/" + destroy + "' " + auth_headers; exec_curl(cmd); cout << GREEN << "Service destroyed: " << destroy << RESET << endl; return; @@ -267,9 +309,10 @@ void cmd_service(const string& name, const string& ports, const string& type, co if (!execute.empty()) { ostringstream json; json << "{\"command\":\"" << escape_json(command) << "\"}"; + string auth_headers = build_auth_headers("POST", "/services/" + execute + "/execute", json.str(), public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + "/services/" + execute + "/execute' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "' " + + auth_headers + " " "-d '" + json.str() + "'"; string result = exec_curl(cmd); @@ -308,10 +351,12 @@ void cmd_service(const string& name, const string& ports, const string& type, co if (!dump_bootstrap.empty()) { cerr << "Fetching bootstrap script from " << dump_bootstrap << "..." << endl; + string json_body = "{\"command\":\"cat /tmp/bootstrap.sh\"}"; + string auth_headers = build_auth_headers("POST", "/services/" + dump_bootstrap + "/execute", json_body, public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + "/services/" + dump_bootstrap + "/execute' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "' " - "-d '{\"command\":\"cat /tmp/bootstrap.sh\"}'"; + + auth_headers + " " + "-d '" + json_body + "'"; string result = exec_curl(cmd); size_t stdout_pos = result.find("\"stdout\":\""); @@ -370,9 +415,10 @@ void cmd_service(const string& name, const string& ports, const string& type, co json << "}"; cout << YELLOW << "Creating service..." << RESET << endl; + string auth_headers = build_auth_headers("POST", "/services", json.str(), public_key, secret_key); string cmd = "curl -s -X POST '" + API_BASE + "/services' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "' " + + auth_headers + " " "-d '" + json.str() + "'"; cout << exec_curl(cmd) << endl; return; @@ -382,10 +428,11 @@ void cmd_service(const string& name, const string& ports, const string& type, co exit(1); } -void cmd_validate_key(bool extend, const string& api_key) { +void cmd_validate_key(bool extend, const string& public_key, const string& secret_key) { + string auth_headers = build_auth_headers("POST", "/keys/validate", "", public_key, secret_key); string cmd = "curl -s -X POST '" + PORTAL_BASE + "/keys/validate' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer " + api_key + "'"; + + auth_headers; string result = exec_curl(cmd); @@ -464,7 +511,13 @@ void cmd_validate_key(bool extend, const string& api_key) { } int main(int argc, char* argv[]) { - string api_key = getenv("UNSANDBOX_API_KEY") ? getenv("UNSANDBOX_API_KEY") : ""; + string public_key = getenv("UNSANDBOX_PUBLIC_KEY") ? getenv("UNSANDBOX_PUBLIC_KEY") : ""; + string secret_key = getenv("UNSANDBOX_SECRET_KEY") ? getenv("UNSANDBOX_SECRET_KEY") : ""; + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (public_key.empty()) { + public_key = getenv("UNSANDBOX_API_KEY") ? getenv("UNSANDBOX_API_KEY") : ""; + } if (argc < 2) { cerr << "Usage: " << argv[0] << " [options] " << endl; @@ -491,10 +544,10 @@ int main(int argc, char* argv[]) { else if (arg == "-v" && i+1 < argc) vcpu = stoi(argv[++i]); else if (arg == "--tmux") tmux = true; else if (arg == "--screen") screen = true; - else if (arg == "-k" && i+1 < argc) api_key = argv[++i]; + else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_session(list, kill, shell, network, vcpu, tmux, screen, api_key); + cmd_session(list, kill, shell, network, vcpu, tmux, screen, public_key, secret_key); return 0; } @@ -523,10 +576,10 @@ int main(int argc, char* argv[]) { else if (arg == "--dump-file" && i+1 < argc) dump_file = argv[++i]; else if (arg == "-n" && i+1 < argc) network = argv[++i]; else if (arg == "-v" && i+1 < argc) vcpu = stoi(argv[++i]); - else if (arg == "-k" && i+1 < argc) api_key = argv[++i]; + else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_service(name, ports, type, bootstrap, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, api_key); + cmd_service(name, ports, type, bootstrap, list, info, logs, tail, sleep, wake, destroy, execute, command, dump_bootstrap, dump_file, network, vcpu, public_key, secret_key); return 0; } @@ -536,10 +589,10 @@ int main(int argc, char* argv[]) { for (int i = 2; i < argc; i++) { string arg = argv[i]; if (arg == "--extend") extend = true; - else if (arg == "-k" && i+1 < argc) api_key = argv[++i]; + else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; } - cmd_validate_key(extend, api_key); + cmd_validate_key(extend, public_key, secret_key); return 0; } @@ -556,7 +609,7 @@ int main(int argc, char* argv[]) { else if (arg == "-a") artifacts = true; else if (arg == "-n" && i+1 < argc) network = argv[++i]; else if (arg == "-v" && i+1 < argc) vcpu = stoi(argv[++i]); - else if (arg == "-k" && i+1 < argc) api_key = argv[++i]; + else if (arg == "-k" && i+1 < argc) public_key = argv[++i]; else if (arg[0] != '-') source_file = arg; } @@ -565,6 +618,6 @@ int main(int argc, char* argv[]) { return 1; } - cmd_execute(source_file, envs, files, artifacts, network, vcpu, api_key); + cmd_execute(source_file, envs, files, artifacts, network, vcpu, public_key, secret_key); return 0; } diff --git a/un.cr b/un.cr index 894f47a..7f3c3c4 100644 --- a/un.cr +++ b/un.cr @@ -41,6 +41,7 @@ require "http/client" require "json" require "base64" require "option_parser" +require "openssl/hmac" # Extension to language mapping EXT_MAP = { @@ -74,28 +75,51 @@ def detect_language(filename : String) : String EXT_MAP.fetch(ext, "unknown") end -def get_api_key(args_key : String?) : String - key = args_key || ENV["UNSANDBOX_API_KEY"]? - if key.nil? || key.empty? - STDERR.puts "#{RED}Error: UNSANDBOX_API_KEY not set#{RESET}" - exit 1 +def get_api_keys(args_key : String?) : {String, String?} + public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? + secret_key = ENV["UNSANDBOX_SECRET_KEY"]? + + # Fall back to UNSANDBOX_API_KEY for backwards compatibility + if public_key.nil? || public_key.empty? || secret_key.nil? || secret_key.empty? + legacy_key = args_key || ENV["UNSANDBOX_API_KEY"]? + if legacy_key.nil? || legacy_key.empty? + STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" + exit 1 + end + return {legacy_key, nil} end - key + + {public_key, secret_key} end -def api_request(endpoint : String, api_key : String, method = "GET", data : JSON::Any? = nil) +def api_request(endpoint : String, public_key : String, secret_key : String?, method = "GET", data : JSON::Any? = nil) url = URI.parse(API_BASE + endpoint) headers = HTTP::Headers{ - "Content-Type" => "application/json", - "Authorization" => "Bearer #{api_key}" + "Content-Type" => "application/json" } + body = data ? data.to_json : "" + + # Add HMAC authentication headers if secret_key is provided + if secret_key && !secret_key.empty? + timestamp = Time.utc.to_unix.to_s + message = "#{timestamp}:#{method}:#{endpoint}:#{body}" + + signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) + + headers["Authorization"] = "Bearer #{public_key}" + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + else + # Legacy API key authentication + headers["Authorization"] = "Bearer #{public_key}" + end + begin response = case method when "GET" HTTP::Client.get(url, headers: headers) when "POST" - body = data ? data.to_json : "" HTTP::Client.post(url, headers: headers, body: body) when "DELETE" HTTP::Client.delete(url, headers: headers) @@ -112,7 +136,7 @@ def api_request(endpoint : String, api_key : String, method = "GET", data : JSON end def cmd_execute(args) - api_key = get_api_key(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?) filename = args[:source_file].as(String) unless File.exists?(filename) @@ -173,7 +197,7 @@ def cmd_execute(args) end # Execute - result = api_request("/execute", api_key, method: "POST", data: payload) + result = api_request("/execute", public_key, secret_key, method: "POST", data: payload) # Print output if stdout = result["stdout"]?.try(&.as_s?) @@ -202,10 +226,10 @@ def cmd_execute(args) end def cmd_session(args) - api_key = get_api_key(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?) if args[:list]?.as?(Bool) - result = api_request("/sessions", api_key) + result = api_request("/sessions", public_key, secret_key) sessions = result["sessions"]?.try(&.as_a?) || [] of JSON::Any if sessions.empty? puts "No active sessions" @@ -223,7 +247,7 @@ def cmd_session(args) end if kill_id = args[:kill]?.as?(String) - api_request("/sessions/#{kill_id}", api_key, method: "DELETE") + api_request("/sessions/#{kill_id}", public_key, secret_key, method: "DELETE") puts "#{GREEN}Session terminated: #{kill_id}#{RESET}" return end @@ -233,17 +257,33 @@ def cmd_session(args) end def cmd_key(args) - api_key = get_api_key(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?) # Validate key url = URI.parse(PORTAL_BASE + "/keys/validate") headers = HTTP::Headers{ - "Content-Type" => "application/json", - "Authorization" => "Bearer #{api_key}" + "Content-Type" => "application/json" } + body = "{}" + + # Add HMAC authentication headers if secret_key is provided + if secret_key && !secret_key.empty? + timestamp = Time.utc.to_unix.to_s + message = "#{timestamp}:POST:/keys/validate:#{body}" + + signature = OpenSSL::HMAC.hexdigest(:sha256, secret_key, message) + + headers["Authorization"] = "Bearer #{public_key}" + headers["X-Timestamp"] = timestamp + headers["X-Signature"] = signature + else + # Legacy API key authentication + headers["Authorization"] = "Bearer #{public_key}" + end + begin - response = HTTP::Client.post(url, headers: headers, body: "{}") + response = HTTP::Client.post(url, headers: headers, body: body) result = JSON.parse(response.body) status = result["status"]?.try(&.as_s?) || "unknown" @@ -311,10 +351,10 @@ def cmd_key(args) end def cmd_service(args) - api_key = get_api_key(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?) if args[:list]?.as?(Bool) - result = api_request("/services", api_key) + result = api_request("/services", public_key, secret_key) services = result["services"]?.try(&.as_a?) || [] of JSON::Any if services.empty? puts "No services" @@ -334,31 +374,31 @@ def cmd_service(args) end if info_id = args[:info]?.as?(String) - result = api_request("/services/#{info_id}", api_key) + result = api_request("/services/#{info_id}", public_key, secret_key) puts result.to_pretty_json return end if logs_id = args[:logs]?.as?(String) - result = api_request("/services/#{logs_id}/logs", api_key) + result = api_request("/services/#{logs_id}/logs", public_key, secret_key) puts result["logs"]?.try(&.as_s?) || "" return end if sleep_id = args[:sleep]?.as?(String) - api_request("/services/#{sleep_id}/sleep", api_key, method: "POST") + api_request("/services/#{sleep_id}/sleep", public_key, secret_key, method: "POST") puts "#{GREEN}Service sleeping: #{sleep_id}#{RESET}" return end if wake_id = args[:wake]?.as?(String) - api_request("/services/#{wake_id}/wake", api_key, method: "POST") + api_request("/services/#{wake_id}/wake", public_key, secret_key, method: "POST") puts "#{GREEN}Service waking: #{wake_id}#{RESET}" return end if destroy_id = args[:destroy]?.as?(String) - api_request("/services/#{destroy_id}", api_key, method: "DELETE") + api_request("/services/#{destroy_id}", public_key, secret_key, method: "DELETE") puts "#{GREEN}Service destroyed: #{destroy_id}#{RESET}" return end @@ -366,7 +406,7 @@ def cmd_service(args) if execute_id = args[:execute]?.as?(String) command = args[:command]?.as?(String) || "" payload = JSON.parse({command: command}.to_json) - result = api_request("/services/#{execute_id}/execute", api_key, method: "POST", data: payload) + result = api_request("/services/#{execute_id}/execute", public_key, secret_key, method: "POST", data: payload) if stdout = result["stdout"]?.try(&.as_s?) print BLUE, stdout, RESET end @@ -379,7 +419,7 @@ def cmd_service(args) if dump_id = args[:dump_bootstrap]?.as?(String) STDERR.puts "Fetching bootstrap script from #{dump_id}..." payload = JSON.parse({command: "cat /tmp/bootstrap.sh"}.to_json) - result = api_request("/services/#{dump_id}/execute", api_key, method: "POST", data: payload) + result = api_request("/services/#{dump_id}/execute", public_key, secret_key, method: "POST", data: payload) if bootstrap = result["stdout"]?.try(&.as_s?) if file_path = args[:dump_file]?.as?(String) @@ -433,7 +473,7 @@ def cmd_service(args) end # Create service - result = api_request("/services", api_key, method: "POST", data: payload) + result = api_request("/services", public_key, secret_key, method: "POST", data: payload) puts "#{GREEN}Service created: #{result["id"]?.try(&.as_s?) || "N/A"}#{RESET}" puts "Name: #{result["name"]?.try(&.as_s?) || "N/A"}" if url = result["url"]?.try(&.as_s?) diff --git a/un.d b/un.d index a3c2ea9..f918112 100644 --- a/un.d +++ b/un.d @@ -88,12 +88,43 @@ string escapeJson(string s) { return result; } +string computeHmac(string secretKey, string message) { + import std.process : pipeShell, Redirect, wait; + import std.stdio : File; + + auto cmd = format("echo -n '%s' | openssl dgst -sha256 -hmac '%s' -hex 2>/dev/null | sed 's/.*= //'", message, secretKey); + auto pipes = pipeShell(cmd, Redirect.stdout); + string result = pipes.stdout.readln().strip(); + wait(pipes.pid); + return result; +} + +string getTimestamp() { + import std.datetime.systime : Clock; + return format("%d", Clock.currTime.toUnixTime()); +} + +string buildAuthHeaders(string method, string path, string body, string publicKey, string secretKey) { + if (secretKey.empty) { + // Legacy mode: use public_key as bearer token + return format("-H 'Authorization: Bearer %s'", publicKey); + } + + // HMAC mode + string timestamp = getTimestamp(); + string message = format("%s:%s:%s:%s", timestamp, method, path, body); + string signature = computeHmac(secretKey, message); + + return format("-H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'", + publicKey, timestamp, signature); +} + string execCurl(string cmd) { auto result = executeShell(cmd); return result.output; } -void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string apiKey) { +void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network, int vcpu, string publicKey, string secretKey) { string lang = detectLanguage(sourceFile); if (lang.empty) { stderr.writefln("%sError: Cannot detect language%s", RED, RESET); @@ -120,21 +151,25 @@ void cmdExecute(string sourceFile, string[] envs, bool artifacts, string network if (vcpu > 0) json ~= format(`,"vcpu":%d`, vcpu); json ~= "}"; - string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '%s'`, API_BASE, apiKey, json); + string authHeaders = buildAuthHeaders("POST", "/execute", json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json); string result = execCurl(cmd); writeln(result); } -void cmdSession(bool list, string kill, string shell, string network, int vcpu, bool tmux, bool screen, string apiKey) { +void cmdSession(bool list, string kill, string shell, string network, int vcpu, bool tmux, bool screen, string publicKey, string secretKey) { if (list) { - string cmd = format(`curl -s -X GET '%s/sessions' -H 'Authorization: Bearer %s'`, API_BASE, apiKey); + string authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/sessions' %s`, API_BASE, authHeaders); writeln(execCurl(cmd)); return; } if (!kill.empty) { - string cmd = format(`curl -s -X DELETE '%s/sessions/%s' -H 'Authorization: Bearer %s'`, API_BASE, kill, apiKey); + string path = format("/sessions/%s", kill); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s/sessions/%s' %s`, API_BASE, kill, authHeaders); execCurl(cmd); writefln("%sSession terminated: %s%s", GREEN, kill, RESET); return; @@ -148,51 +183,65 @@ void cmdSession(bool list, string kill, string shell, string network, int vcpu, json ~= "}"; writefln("%sCreating session...%s", YELLOW, RESET); - string cmd = format(`curl -s -X POST '%s/sessions' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '%s'`, API_BASE, apiKey, json); + string authHeaders = buildAuthHeaders("POST", "/sessions", json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/sessions' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json); writeln(execCurl(cmd)); } -void cmdService(string name, string ports, string bootstrap, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string apiKey) { +void cmdService(string name, string ports, string bootstrap, string type, bool list, string info, string logs, string tail, string sleep, string wake, string destroy, string execute, string command, string dumpBootstrap, string dumpFile, string network, int vcpu, string publicKey, string secretKey) { if (list) { - string cmd = format(`curl -s -X GET '%s/services' -H 'Authorization: Bearer %s'`, API_BASE, apiKey); + string authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/services' %s`, API_BASE, authHeaders); writeln(execCurl(cmd)); return; } if (!info.empty) { - string cmd = format(`curl -s -X GET '%s/services/%s' -H 'Authorization: Bearer %s'`, API_BASE, info, apiKey); + string path = format("/services/%s", info); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/services/%s' %s`, API_BASE, info, authHeaders); writeln(execCurl(cmd)); return; } if (!logs.empty) { - string cmd = format(`curl -s -X GET '%s/services/%s/logs' -H 'Authorization: Bearer %s'`, API_BASE, logs, apiKey); + string path = format("/services/%s/logs", logs); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/services/%s/logs' %s`, API_BASE, logs, authHeaders); write(execCurl(cmd)); return; } if (!tail.empty) { - string cmd = format(`curl -s -X GET '%s/services/%s/logs?lines=9000' -H 'Authorization: Bearer %s'`, API_BASE, tail, apiKey); + string path = format("/services/%s/logs", tail); + string authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/services/%s/logs?lines=9000' %s`, API_BASE, tail, authHeaders); write(execCurl(cmd)); return; } if (!sleep.empty) { - string cmd = format(`curl -s -X POST '%s/services/%s/sleep' -H 'Authorization: Bearer %s'`, API_BASE, sleep, apiKey); + string path = format("/services/%s/sleep", sleep); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services/%s/sleep' %s`, API_BASE, sleep, authHeaders); execCurl(cmd); writefln("%sService sleeping: %s%s", GREEN, sleep, RESET); return; } if (!wake.empty) { - string cmd = format(`curl -s -X POST '%s/services/%s/wake' -H 'Authorization: Bearer %s'`, API_BASE, wake, apiKey); + string path = format("/services/%s/wake", wake); + string authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services/%s/wake' %s`, API_BASE, wake, authHeaders); execCurl(cmd); writefln("%sService waking: %s%s", GREEN, wake, RESET); return; } if (!destroy.empty) { - string cmd = format(`curl -s -X DELETE '%s/services/%s' -H 'Authorization: Bearer %s'`, API_BASE, destroy, apiKey); + string path = format("/services/%s", destroy); + string authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey); + string cmd = format(`curl -s -X DELETE '%s/services/%s' %s`, API_BASE, destroy, authHeaders); execCurl(cmd); writefln("%sService destroyed: %s%s", GREEN, destroy, RESET); return; @@ -200,7 +249,9 @@ void cmdService(string name, string ports, string bootstrap, string type, bool l if (!execute.empty) { string json = format(`{"command":"%s"}`, escapeJson(command)); - string cmd = format(`curl -s -X POST '%s/services/%s/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '%s'`, API_BASE, execute, apiKey, json); + string path = format("/services/%s/execute", execute); + string authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services/%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, execute, authHeaders, json); string result = execCurl(cmd); // Simple JSON parsing for stdout/stderr @@ -229,7 +280,10 @@ void cmdService(string name, string ports, string bootstrap, string type, bool l if (!dumpBootstrap.empty) { stderr.writefln("Fetching bootstrap script from %s...", dumpBootstrap); - string cmd = format(`curl -s -X POST '%s/services/%s/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '{"command":"cat /tmp/bootstrap.sh"}'`, API_BASE, dumpBootstrap, apiKey); + string json = `{"command":"cat /tmp/bootstrap.sh"}`; + string path = format("/services/%s/execute", dumpBootstrap); + string authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services/%s/execute' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, dumpBootstrap, authHeaders, json); string result = execCurl(cmd); import std.algorithm : findSplitAfter; @@ -283,7 +337,8 @@ void cmdService(string name, string ports, string bootstrap, string type, bool l json ~= "}"; writefln("%sCreating service...%s", YELLOW, RESET); - string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '%s'`, API_BASE, apiKey, json); + string authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey); + string cmd = format(`curl -s -X POST '%s/services' -H 'Content-Type: application/json' %s -d '%s'`, API_BASE, authHeaders, json); writeln(execCurl(cmd)); return; } @@ -318,11 +373,12 @@ string formatDuration(long totalMinutes) { } } -void validateKey(string apiKey, bool extend) { +void validateKey(string publicKey, string secretKey, bool extend) { import std.json; import std.datetime; - string cmd = format(`curl -s -w '\n%%{http_code}' -X POST '%s/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer %s'`, PORTAL_BASE, apiKey); + string authHeaders = buildAuthHeaders("POST", "/keys/validate", "", publicKey, secretKey); + string cmd = format(`curl -s -w '\n%%{http_code}' -X POST '%s/keys/validate' -H 'Content-Type: application/json' %s`, PORTAL_BASE, authHeaders); string response = execCurl(cmd); auto lines = response.split("\n"); @@ -413,7 +469,13 @@ void validateKey(string apiKey, bool extend) { } int main(string[] args) { - string apiKey = environment.get("UNSANDBOX_API_KEY", ""); + string publicKey = environment.get("UNSANDBOX_PUBLIC_KEY", ""); + string secretKey = environment.get("UNSANDBOX_SECRET_KEY", ""); + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (publicKey.empty) { + publicKey = environment.get("UNSANDBOX_API_KEY", ""); + } if (args.length < 2) { stderr.writefln("Usage: %s [options] ", args[0]); @@ -437,10 +499,10 @@ int main(string[] args) { else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); else if (args[i] == "--tmux") tmux = true; else if (args[i] == "--screen") screen = true; - else if (args[i] == "-k" && i+1 < args.length) apiKey = args[++i]; + else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdSession(list, kill, shell, network, vcpu, tmux, screen, apiKey); + cmdSession(list, kill, shell, network, vcpu, tmux, screen, publicKey, secretKey); return 0; } @@ -468,10 +530,10 @@ int main(string[] args) { else if (args[i] == "--dump-file" && i+1 < args.length) dumpFile = args[++i]; else if (args[i] == "-n" && i+1 < args.length) network = args[++i]; else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); - else if (args[i] == "-k" && i+1 < args.length) apiKey = args[++i]; + else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - cmdService(name, ports, bootstrap, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, apiKey); + cmdService(name, ports, bootstrap, type, list, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network, vcpu, publicKey, secretKey); return 0; } @@ -480,15 +542,15 @@ int main(string[] args) { for (size_t i = 2; i < args.length; i++) { if (args[i] == "--extend") extend = true; - else if (args[i] == "-k" && i+1 < args.length) apiKey = args[++i]; + else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; } - if (apiKey.empty) { - stderr.writefln("%sError: UNSANDBOX_API_KEY not set%s", RED, RESET); + if (publicKey.empty) { + stderr.writefln("%sError: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set%s", RED, RESET); return 1; } - validateKey(apiKey, extend); + validateKey(publicKey, secretKey, extend); return 0; } @@ -503,7 +565,7 @@ int main(string[] args) { else if (args[i] == "-a") artifacts = true; else if (args[i] == "-n" && i+1 < args.length) network = args[++i]; else if (args[i] == "-v" && i+1 < args.length) vcpu = to!int(args[++i]); - else if (args[i] == "-k" && i+1 < args.length) apiKey = args[++i]; + else if (args[i] == "-k" && i+1 < args.length) publicKey = args[++i]; else if (!args[i].startsWith("-")) sourceFile = args[i]; } @@ -512,6 +574,6 @@ int main(string[] args) { return 1; } - cmdExecute(sourceFile, envs, artifacts, network, vcpu, apiKey); + cmdExecute(sourceFile, envs, artifacts, network, vcpu, publicKey, secretKey); return 0; } diff --git a/un.dart b/un.dart index 8e6831a..5f556d1 100644 --- a/un.dart +++ b/un.dart @@ -42,6 +42,7 @@ import 'dart:io'; import 'dart:convert'; +import 'package:crypto/crypto.dart'; const String apiBase = 'https://api.unsandbox.com'; const String portalBase = 'https://unsandbox.com'; @@ -98,13 +99,21 @@ class Args { bool keyExtend = false; } -String getApiKey(String? argsKey) { - final key = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; - if (key == null || key.isEmpty) { - stderr.writeln('${red}Error: UNSANDBOX_API_KEY not set$reset'); - exit(1); +List getApiKeys(String? argsKey) { + final publicKey = Platform.environment['UNSANDBOX_PUBLIC_KEY']; + final secretKey = Platform.environment['UNSANDBOX_SECRET_KEY']; + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (publicKey == null || publicKey.isEmpty || secretKey == null || secretKey.isEmpty) { + final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; + if (legacyKey == null || legacyKey.isEmpty) { + stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); + exit(1); + } + return [legacyKey, null]; } - return key; + + return [publicKey, secretKey]; } String detectLanguage(String filename) { @@ -120,18 +129,37 @@ String detectLanguage(String filename) { return lang; } -Future> apiRequestCurl(String endpoint, String method, String? jsonData, String apiKey, {String? baseUrl}) async { +Future> apiRequestCurl(String endpoint, String method, String? jsonData, String publicKey, String? secretKey, {String? baseUrl}) async { final base = baseUrl ?? apiBase; final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.json').create(); try { + final body = jsonData ?? ''; if (jsonData != null) { await tempFile.writeAsString(jsonData); } final args = ['curl', '-s', '-X', method, '$base$endpoint', - '-H', 'Content-Type: application/json', - '-H', 'Authorization: Bearer $apiKey']; + '-H', 'Content-Type: application/json']; + + // Add HMAC authentication headers if secretKey is provided + if (secretKey != null && secretKey.isNotEmpty) { + final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); + final message = '$timestamp:$method:$endpoint:$body'; + + final key = utf8.encode(secretKey); + final bytes = utf8.encode(message); + final hmacSha256 = Hmac(sha256, key); + final digest = hmacSha256.convert(bytes); + final signature = digest.toString(); + + args.addAll(['-H', 'Authorization: Bearer $publicKey']); + args.addAll(['-H', 'X-Timestamp: $timestamp']); + args.addAll(['-H', 'X-Signature: $signature']); + } else { + // Legacy API key authentication + args.addAll(['-H', 'Authorization: Bearer $publicKey']); + } if (jsonData != null) { args.addAll(['-d', '@${tempFile.path}']); @@ -151,7 +179,9 @@ Future> apiRequestCurl(String endpoint, String method, Stri } Future cmdExecute(Args args) async { - final apiKey = getApiKey(args.apiKey); + final keys = getApiKeys(args.apiKey); + final publicKey = keys[0]!; + final secretKey = keys[1]; final code = await File(args.sourceFile!).readAsString(); final language = detectLanguage(args.sourceFile!); @@ -195,7 +225,7 @@ Future cmdExecute(Args args) async { payload['vcpu'] = args.vcpu; } - final result = await apiRequestCurl('/execute', 'POST', jsonEncode(payload), apiKey); + final result = await apiRequestCurl('/execute', 'POST', jsonEncode(payload), publicKey, secretKey); final stdoutText = result['stdout'] as String?; final stderrText = result['stderr'] as String?; @@ -227,10 +257,12 @@ Future cmdExecute(Args args) async { } Future cmdSession(Args args) async { - final apiKey = getApiKey(args.apiKey); + final keys = getApiKeys(args.apiKey); + final publicKey = keys[0]!; + final secretKey = keys[1]; if (args.sessionList) { - final result = await apiRequestCurl('/sessions', 'GET', null, apiKey); + final result = await apiRequestCurl('/sessions', 'GET', null, publicKey, secretKey); final sessions = result['sessions'] as List? ?? []; if (sessions.isEmpty) { print('No active sessions'); @@ -245,7 +277,7 @@ Future cmdSession(Args args) async { } if (args.sessionKill != null) { - await apiRequestCurl('/sessions/${args.sessionKill}', 'DELETE', null, apiKey); + await apiRequestCurl('/sessions/${args.sessionKill}', 'DELETE', null, publicKey, secretKey); print('${green}Session terminated: ${args.sessionKill}$reset'); return; } @@ -261,16 +293,18 @@ Future cmdSession(Args args) async { } print('${yellow}Creating session...$reset'); - final result = await apiRequestCurl('/sessions', 'POST', jsonEncode(payload), apiKey); + final result = await apiRequestCurl('/sessions', 'POST', jsonEncode(payload), publicKey, secretKey); print('${green}Session created: ${result['id'] ?? 'N/A'}$reset'); print('${yellow}(Interactive sessions require WebSocket - use un2 for full support)$reset'); } Future cmdService(Args args) async { - final apiKey = getApiKey(args.apiKey); + final keys = getApiKeys(args.apiKey); + final publicKey = keys[0]!; + final secretKey = keys[1]; if (args.serviceList) { - final result = await apiRequestCurl('/services', 'GET', null, apiKey); + final result = await apiRequestCurl('/services', 'GET', null, publicKey, secretKey); final services = result['services'] as List? ?? []; if (services.isEmpty) { print('No services'); @@ -287,37 +321,37 @@ Future cmdService(Args args) async { } if (args.serviceInfo != null) { - final result = await apiRequestCurl('/services/${args.serviceInfo}', 'GET', null, apiKey); + final result = await apiRequestCurl('/services/${args.serviceInfo}', 'GET', null, publicKey, secretKey); print(jsonEncode(result)); return; } if (args.serviceLogs != null) { - final result = await apiRequestCurl('/services/${args.serviceLogs}/logs', 'GET', null, apiKey); + final result = await apiRequestCurl('/services/${args.serviceLogs}/logs', 'GET', null, publicKey, secretKey); print(result['logs'] ?? ''); return; } if (args.serviceTail != null) { - final result = await apiRequestCurl('/services/${args.serviceTail}/logs?lines=9000', 'GET', null, apiKey); + final result = await apiRequestCurl('/services/${args.serviceTail}/logs?lines=9000', 'GET', null, publicKey, secretKey); print(result['logs'] ?? ''); return; } if (args.serviceSleep != null) { - await apiRequestCurl('/services/${args.serviceSleep}/sleep', 'POST', null, apiKey); + await apiRequestCurl('/services/${args.serviceSleep}/sleep', 'POST', null, publicKey, secretKey); print('${green}Service sleeping: ${args.serviceSleep}$reset'); return; } if (args.serviceWake != null) { - await apiRequestCurl('/services/${args.serviceWake}/wake', 'POST', null, apiKey); + await apiRequestCurl('/services/${args.serviceWake}/wake', 'POST', null, publicKey, secretKey); print('${green}Service waking: ${args.serviceWake}$reset'); return; } if (args.serviceDestroy != null) { - await apiRequestCurl('/services/${args.serviceDestroy}', 'DELETE', null, apiKey); + await apiRequestCurl('/services/${args.serviceDestroy}', 'DELETE', null, publicKey, secretKey); print('${green}Service destroyed: ${args.serviceDestroy}$reset'); return; } @@ -326,7 +360,7 @@ Future cmdService(Args args) async { final payload = { 'command': args.serviceCommand, }; - final result = await apiRequestCurl('/services/${args.serviceExecute}/execute', 'POST', jsonEncode(payload), apiKey); + final result = await apiRequestCurl('/services/${args.serviceExecute}/execute', 'POST', jsonEncode(payload), publicKey, secretKey); final stdoutText = result['stdout'] as String?; final stderrText = result['stderr'] as String?; if (stdoutText != null && stdoutText.isNotEmpty) { @@ -343,7 +377,7 @@ Future cmdService(Args args) async { final payload = { 'command': 'cat /tmp/bootstrap.sh', }; - final result = await apiRequestCurl('/services/${args.serviceDumpBootstrap}/execute', 'POST', jsonEncode(payload), apiKey); + final result = await apiRequestCurl('/services/${args.serviceDumpBootstrap}/execute', 'POST', jsonEncode(payload), publicKey, secretKey); final bootstrap = result['stdout'] as String?; if (bootstrap != null && bootstrap.isNotEmpty) { @@ -386,7 +420,7 @@ Future cmdService(Args args) async { payload['vcpu'] = args.vcpu; } - final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), apiKey); + final result = await apiRequestCurl('/services', 'POST', jsonEncode(payload), publicKey, secretKey); print('${green}Service created: ${result['id'] ?? 'N/A'}$reset'); print('Name: ${result['name'] ?? 'N/A'}'); if (result.containsKey('url')) { @@ -400,10 +434,12 @@ Future cmdService(Args args) async { } Future cmdKey(Args args) async { - final apiKey = getApiKey(args.apiKey); + final keys = getApiKeys(args.apiKey); + final publicKey = keys[0]!; + final secretKey = keys[1]; try { - final result = await apiRequestCurl('/keys/validate', 'POST', null, apiKey, baseUrl: portalBase); + final result = await apiRequestCurl('/keys/validate', 'POST', null, publicKey, secretKey, baseUrl: portalBase); // Handle --extend flag if (args.keyExtend) { diff --git a/un.erl b/un.erl index c164e47..816a5b4 100755 --- a/un.erl +++ b/un.erl @@ -294,12 +294,44 @@ open_extend_page(PublicKey) -> end. %% Helpers +get_api_keys() -> + PublicKey = os:getenv("UNSANDBOX_PUBLIC_KEY"), + SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"), + ApiKey = os:getenv("UNSANDBOX_API_KEY"), + + if + PublicKey =/= false andalso SecretKey =/= false -> + {PublicKey, SecretKey}; + ApiKey =/= false -> + {ApiKey, false}; + true -> + io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"), + halt(1) + end. + get_api_key() -> - case os:getenv("UNSANDBOX_API_KEY") of - false -> - io:format("Error: UNSANDBOX_API_KEY not set~n"), - halt(1); - Key -> Key + {PublicKey, _} = get_api_keys(), + PublicKey. + +hmac_sha256(Secret, Message) -> + string:lowercase( + lists:flatten([io_lib:format("~2.16.0b", [X]) || X <- binary_to_list(crypto:mac(hmac, sha256, Secret, Message))]) + ). + +make_signature(SecretKey, Timestamp, Method, Path, Body) -> + Message = Timestamp ++ ":" ++ Method ++ ":" ++ Path ++ ":" ++ Body, + hmac_sha256(SecretKey, Message). + +build_auth_headers(PublicKey, SecretKey, Method, Path, Body) -> + if + SecretKey =/= false -> + Timestamp = integer_to_list(erlang:system_time(second)), + Signature = make_signature(SecretKey, Timestamp, Method, Path, Body), + " -H 'Authorization: Bearer " ++ PublicKey ++ "'" + ++ " -H 'X-Timestamp: " ++ Timestamp ++ "'" + ++ " -H 'X-Signature: " ++ Signature ++ "'"; + true -> + " -H 'Authorization: Bearer " ++ PublicKey ++ "'" end. ext_to_lang(".hs") -> {ok, "haskell"}; @@ -364,30 +396,40 @@ write_temp_file(Data) -> TmpFile. curl_post(ApiKey, Endpoint, TmpFile) -> + {ok, Body} = file:read_file(TmpFile), + BodyStr = binary_to_list(Body), + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, BodyStr), Cmd = "curl -s -X POST https://api.unsandbox.com" ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ - " -H 'Authorization: Bearer " ++ ApiKey ++ "'" ++ + AuthHeaders ++ " -d @" ++ TmpFile, os:cmd(Cmd). curl_post_portal(ApiKey, Endpoint, Data) -> TmpFile = write_temp_file(Data), + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Data), Cmd = "curl -s -X POST https://unsandbox.com" ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ - " -H 'Authorization: Bearer " ++ ApiKey ++ "'" ++ + AuthHeaders ++ " -d @" ++ TmpFile, Result = os:cmd(Cmd), file:delete(TmpFile), Result. curl_get(ApiKey, Endpoint) -> + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "GET", Endpoint, ""), Cmd = "curl -s https://api.unsandbox.com" ++ Endpoint ++ - " -H 'Authorization: Bearer " ++ ApiKey ++ "'", + AuthHeaders, os:cmd(Cmd). curl_delete(ApiKey, Endpoint) -> + {PublicKey, SecretKey} = get_api_keys(), + AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""), Cmd = "curl -s -X DELETE https://api.unsandbox.com" ++ Endpoint ++ - " -H 'Authorization: Bearer " ++ ApiKey ++ "'", + AuthHeaders, os:cmd(Cmd). %% Argument parsing diff --git a/un.ex b/un.ex index e63a5e2..eba8f7c 100755 --- a/un.ex +++ b/un.ex @@ -401,15 +401,39 @@ defmodule Un do end # Helpers - defp get_api_key do - case System.get_env("UNSANDBOX_API_KEY") do - nil -> - IO.puts(:stderr, "Error: UNSANDBOX_API_KEY not set") + defp get_api_keys do + public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") + secret_key = System.get_env("UNSANDBOX_SECRET_KEY") + + # Fall back to UNSANDBOX_API_KEY for backwards compatibility + api_key = System.get_env("UNSANDBOX_API_KEY") + + cond do + public_key && secret_key -> + {public_key, secret_key} + api_key -> + {api_key, nil} + true -> + IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") System.halt(1) - key -> key end end + defp get_api_key do + {public_key, _} = get_api_keys() + public_key + end + + defp hmac_sha256(secret, message) do + :crypto.mac(:hmac, :sha256, secret, message) + |> Base.encode16(case: :lower) + end + + defp make_signature(secret_key, timestamp, method, path, body) do + message = "#{timestamp}:#{method}:#{path}:#{body}" + hmac_sha256(secret_key, message) + end + defp escape_json(s) do s |> String.replace("\\", "\\\\") @@ -427,50 +451,79 @@ defmodule Un do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" File.write!(tmp_file, json) - {output, _exit} = System.cmd("curl", [ + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) + + args = [ "-s", "-X", "POST", "https://api.unsandbox.com#{endpoint}", - "-H", "Content-Type: application/json", - "-H", "Authorization: Bearer #{api_key}", - "-d", "@#{tmp_file}" - ], stderr_to_stdout: true) + "-H", "Content-Type: application/json" + ] ++ headers ++ ["-d", "@#{tmp_file}"] + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) output end + defp build_auth_headers(public_key, secret_key, method, path, body) do + if secret_key do + timestamp = System.system_time(:second) |> Integer.to_string() + signature = make_signature(secret_key, timestamp, method, path, body) + [ + "-H", "Authorization: Bearer #{public_key}", + "-H", "X-Timestamp: #{timestamp}", + "-H", "X-Signature: #{signature}" + ] + else + # Backwards compatibility: use simple bearer token + ["-H", "Authorization: Bearer #{public_key}"] + end + end + defp portal_curl_post(api_key, endpoint, json) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" File.write!(tmp_file, json) - {output, _exit} = System.cmd("curl", [ + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) + + args = [ "-s", "-X", "POST", "#{@portal_base}#{endpoint}", - "-H", "Content-Type: application/json", - "-H", "Authorization: Bearer #{api_key}", - "-d", "@#{tmp_file}" - ], stderr_to_stdout: true) + "-H", "Content-Type: application/json" + ] ++ headers ++ ["-d", "@#{tmp_file}"] + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) output end defp curl_get(api_key, endpoint) do - {output, _exit} = System.cmd("curl", [ + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "GET", endpoint, "") + + args = [ "-s", - "https://api.unsandbox.com#{endpoint}", - "-H", "Authorization: Bearer #{api_key}" - ], stderr_to_stdout: true) + "https://api.unsandbox.com#{endpoint}" + ] ++ headers + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) output end defp curl_delete(api_key, endpoint) do - {output, _exit} = System.cmd("curl", [ + {public_key, secret_key} = get_api_keys() + headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") + + args = [ "-s", "-X", "DELETE", - "https://api.unsandbox.com#{endpoint}", - "-H", "Authorization: Bearer #{api_key}" - ], stderr_to_stdout: true) + "https://api.unsandbox.com#{endpoint}" + ] ++ headers + + {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) output end diff --git a/un.f90 b/un.f90 index 722013e..f649300 100644 --- a/un.f90 +++ b/un.f90 @@ -87,8 +87,8 @@ contains subroutine handle_execute(fname) character(len=*), intent(in) :: fname - character(len=2048) :: full_cmd - character(len=1024) :: env_opts, file_opts, net_opt + character(len=4096) :: full_cmd + character(len=1024) :: env_opts, file_opts, net_opt, public_key, secret_key integer :: i, arg_idx logical :: artifacts, has_env, has_files @@ -131,11 +131,23 @@ contains stop 1 end if - ! Get API key - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_API_KEY environment variable not set' - stop 1 + ! Get API keys (try new format first, fall back to old) + call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) + if (stat == 0 .and. len_trim(public_key) > 0) then + call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) + if (stat /= 0 .or. len_trim(secret_key) == 0) then + write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set' + stop 1 + end if + else + ! Fall back to old-style single key + call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) + if (stat /= 0 .or. len_trim(api_key) == 0) then + write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set' + stop 1 + end if + public_key = api_key + secret_key = api_key end if ! Parse additional arguments (simple version - only support basic flags) @@ -144,14 +156,17 @@ contains net_opt = '' artifacts = .false. - ! Build curl command - write(full_cmd, '(20A)') & + ! Build curl command with HMAC auth (use bash to compute signature) + write(full_cmd, '(30A)') & + 'TS=$(date +%s); ', & + 'BODY=$(jq -Rs ''{language: "', trim(language), '", code: .}'' < "', trim(fname), '"); ', & + 'SIG=$(echo -n "$TS:POST:/execute:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X POST https://api.unsandbox.com/execute ', & '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(api_key), '" ', & - '--data-binary @- -o /tmp/unsandbox_resp.json ', & - '< <(jq -Rs ''{language: "', trim(language), '", code: .}'' ', & - '< "', trim(fname), '"); ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '--data-binary "$BODY" -o /tmp/unsandbox_resp.json; ', & 'jq -r ".stdout // empty" /tmp/unsandbox_resp.json | ', & 'sed "s/^/\x1b[34m/" | sed "s/$/\x1b[0m/"; ', & 'jq -r ".stderr // empty" /tmp/unsandbox_resp.json | ', & @@ -167,8 +182,9 @@ contains end subroutine handle_execute subroutine handle_session() - character(len=2048) :: full_cmd + character(len=4096) :: full_cmd character(len=256) :: arg, session_id + character(len=1024) :: public_key, secret_key integer :: i, stat logical :: list_mode, kill_mode @@ -189,27 +205,46 @@ contains end if end do - ! Get API key - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_API_KEY not set' - stop 1 + ! Get API keys (try new format first, fall back to old) + call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) + if (stat == 0 .and. len_trim(public_key) > 0) then + call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) + if (stat /= 0 .or. len_trim(secret_key) == 0) then + write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set' + stop 1 + end if + else + call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) + if (stat /= 0 .or. len_trim(api_key) == 0) then + write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set' + stop 1 + end if + public_key = api_key + secret_key = api_key end if if (list_mode) then - ! List sessions - write(full_cmd, '(10A)') & + ! List sessions - GET request with empty body + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/sessions:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X GET https://api.unsandbox.com/sessions ', & - '-H "Authorization: Bearer ', trim(api_key), '" | ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | ', & 'jq -r ''.sessions[] | "\(.id) \(.shell) \(.status) \(.created_at)"'' ', & '2>/dev/null || echo "No active sessions"' call execute_command_line(trim(full_cmd), wait=.true.) else if (kill_mode .and. len_trim(session_id) > 0) then - ! Kill session - write(full_cmd, '(10A)') & + ! Kill session - DELETE request with empty body + write(full_cmd, '(20A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:DELETE:/sessions/', trim(session_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & 'curl -s -X DELETE https://api.unsandbox.com/sessions/', & trim(session_id), ' ', & - '-H "Authorization: Bearer ', trim(api_key), '" >/dev/null && ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" >/dev/null && ', & 'echo -e "\x1b[32mSession terminated: ', trim(session_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else diff --git a/un.forth b/un.forth index 0240f89..9f73713 100644 --- a/un.forth +++ b/un.forth @@ -91,15 +91,30 @@ find-ext ext-lang ; -\ Get API key from environment -: get-api-key ( -- addr len ) - s" UNSANDBOX_API_KEY" getenv +\ Get API keys from environment (HMAC or legacy) +: get-public-key ( -- addr len ) + s" UNSANDBOX_PUBLIC_KEY" getenv dup 0= if - s" Error: UNSANDBOX_API_KEY not set" type cr + 2drop s" UNSANDBOX_API_KEY" getenv + then + dup 0= if + s" Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set" type cr 1 (bye) then ; +: get-secret-key ( -- addr len ) + s" UNSANDBOX_SECRET_KEY" getenv + dup 0= if + 2drop s" UNSANDBOX_API_KEY" getenv + then +; + +\ Get API key (legacy compatibility) +: get-api-key ( -- addr len ) + get-public-key +; + \ Execute a file : execute-file ( addr len -- ) \ Check file exists @@ -125,8 +140,11 @@ s" /tmp/unsandbox_script.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" API_KEY='" r@ write-file throw - get-api-key r@ write-file throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw s" '" r@ write-line throw s" LANG='" r@ write-file throw 2swap 2drop \ drop language, keep filename on stack @@ -135,7 +153,11 @@ s" FILE='" r@ write-file throw r@ write-file throw s" '" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/execute -H 'Content-Type: application/json' -H \"Authorization: Bearer $API_KEY\" --data-binary @- -o /tmp/unsandbox_resp.json < <(jq -Rs '{language: \"'$LANG'\", code: .}' < \"$FILE\"); jq -r '.stdout // empty' /tmp/unsandbox_resp.json | sed 's/^/\\x1b[34m/' | sed 's/$/\\x1b[0m/'; jq -r '.stderr // empty' /tmp/unsandbox_resp.json | sed 's/^/\\x1b[31m/' | sed 's/$/\\x1b[0m/' >&2; rm -f /tmp/unsandbox_resp.json" r@ write-line throw + s" BODY=$(jq -Rs '{language: \"'$LANG'\", code: .}' < \"$FILE\")" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/execute:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/execute -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" -o /tmp/unsandbox_resp.json; jq -r '.stdout // empty' /tmp/unsandbox_resp.json | sed 's/^/\\x1b[34m/' | sed 's/$/\\x1b[0m/'; jq -r '.stderr // empty' /tmp/unsandbox_resp.json | sed 's/^/\\x1b[31m/' | sed 's/$/\\x1b[0m/' >&2; rm -f /tmp/unsandbox_resp.json" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_script.sh && /tmp/unsandbox_script.sh && rm -f /tmp/unsandbox_script.sh" system @@ -147,9 +169,16 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X GET https://api.unsandbox.com/sessions -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' | jq -r '.sessions[] | \"\\(.id) \\(.shell) \\(.status) \\(.created_at)\"' 2>/dev/null || echo 'No active sessions'" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/sessions:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/sessions -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.sessions[] | \"\\(.id) \\(.shell) \\(.status) \\(.created_at)\"' 2>/dev/null || echo 'No active sessions'" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -159,11 +188,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X DELETE https://api.unsandbox.com/sessions/" r@ write-file throw + s" SESSION_ID='" r@ write-file throw 2dup r@ write-file throw - s" -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' >/dev/null && echo -e '\\x1b[32mSession terminated: " r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/sessions/$SESSION_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X DELETE https://api.unsandbox.com/sessions/$SESSION_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mSession terminated: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw r> close-file throw @@ -175,9 +212,16 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X GET https://api.unsandbox.com/services -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' | jq -r '.services[] | \"\\(.id) \\(.name) \\(.status)\"' 2>/dev/null || echo 'No services'" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/services:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/services -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.services[] | \"\\(.id) \\(.name) \\(.status)\"' 2>/dev/null || echo 'No services'" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -187,11 +231,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X GET https://api.unsandbox.com/services/" r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2dup r@ write-file throw - s" -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' | jq ." r@ write-line throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/services/$SERVICE_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq ." r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -201,11 +253,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X GET https://api.unsandbox.com/services/" r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2dup r@ write-file throw - s" /logs -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' | jq -r '.logs'" r@ write-line throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:GET:/services/$SERVICE_ID/logs:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X GET https://api.unsandbox.com/services/$SERVICE_ID/logs -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.logs'" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -215,11 +275,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/services/" r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2dup r@ write-file throw - s" /sleep -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' >/dev/null && echo -e '\\x1b[32mService sleeping: " r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/services/$SERVICE_ID/sleep:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/services/$SERVICE_ID/sleep -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mService sleeping: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw r> close-file throw @@ -231,11 +299,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/services/" r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2dup r@ write-file throw - s" /wake -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' >/dev/null && echo -e '\\x1b[32mService waking: " r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/services/$SERVICE_ID/wake:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/services/$SERVICE_ID/wake -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mService waking: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw r> close-file throw @@ -247,11 +323,19 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" curl -s -X DELETE https://api.unsandbox.com/services/" r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2dup r@ write-file throw - s" -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' >/dev/null && echo -e '\\x1b[32mService destroyed: " r@ write-file throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:DELETE:/services/$SERVICE_ID:\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X DELETE https://api.unsandbox.com/services/$SERVICE_ID -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" >/dev/null && echo -e '\\x1b[32mService destroyed: " r@ write-file throw r@ write-file throw s" \\x1b[0m'" r@ write-line throw r> close-file throw @@ -263,14 +347,21 @@ get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" echo 'Fetching bootstrap script from " r@ write-file throw + s" SERVICE_ID='" r@ write-file throw 2over r@ write-file throw - s" ...' >&2" r@ write-line throw - s" RESP=$(curl -s -X POST https://api.unsandbox.com/services/" r@ write-file throw - 2over r@ write-file throw - s" /execute -H 'Content-Type: application/json' -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' -d '{\"command\":\"cat /tmp/bootstrap.sh\"}')" r@ write-line throw + s" '" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw + s" echo 'Fetching bootstrap script from $SERVICE_ID...' >&2" r@ write-line throw + s" BODY='{\"command\":\"cat /tmp/bootstrap.sh\"}'" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/services/$SERVICE_ID/execute:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" RESP=$(curl -s -X POST https://api.unsandbox.com/services/$SERVICE_ID/execute -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\")" r@ write-line throw s" STDOUT=$(echo \"$RESP\" | jq -r '.stdout // empty')" r@ write-line throw s" if [ -n \"$STDOUT\" ]; then" r@ write-line throw 2dup 0 0 d= if @@ -302,6 +393,12 @@ \ For now, just create the curl command that will be constructed by bash s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw + s" '" r@ write-line throw s" NAME=''; PORTS=''; DOMAINS=''; TYPE=''; BOOTSTRAP=''" r@ write-line throw s" for ((i=3; i<$#; i+=2)); do" r@ write-line throw s" case ${!i} in" r@ write-line throw @@ -318,9 +415,10 @@ s" [ -n \"$DOMAINS\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg d \"$DOMAINS\" '. + {domains: ($d | split(\",\"))}')" r@ write-line throw s" [ -n \"$TYPE\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg t \"$TYPE\" '. + {service_type: $t}')" r@ write-line throw s" [ -n \"$BOOTSTRAP\" ] && PAYLOAD=$(echo $PAYLOAD | jq --arg b \"$BOOTSTRAP\" '. + {bootstrap: $b}')" r@ write-line throw - s" curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H 'Authorization: Bearer " r@ write-file throw - get-api-key r@ write-file throw - s" ' -d \"$PAYLOAD\" | jq ." r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/services:$PAYLOAD\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw + s" curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$PAYLOAD\" | jq ." r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && bash /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; @@ -330,17 +428,24 @@ get-api-key s" /tmp/unsandbox_key_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw - s" API_KEY='" r@ write-file throw - get-api-key r@ write-file throw + s" PUBLIC_KEY='" r@ write-file throw + get-public-key r@ write-file throw + s" '" r@ write-line throw + s" SECRET_KEY='" r@ write-file throw + get-secret-key r@ write-file throw s" '" r@ write-line throw s" PORTAL_BASE='" r@ write-file throw portal-base r@ write-file throw s" '" r@ write-line throw + s" BODY='{}'" r@ write-line throw + s" TIMESTAMP=$(date +%s)" r@ write-line throw + s" MESSAGE=\"$TIMESTAMP:POST:/keys/validate:$BODY\"" r@ write-line throw + s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw \ Check if extend flag is set 0= if \ Normal validation - s" curl -s -X POST $PORTAL_BASE/keys/validate -H 'Content-Type: application/json' -H \"Authorization: Bearer $API_KEY\" -o /tmp/unsandbox_key_resp.json" r@ write-line throw + s" curl -s -X POST $PORTAL_BASE/keys/validate -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\" -o /tmp/unsandbox_key_resp.json" r@ write-line throw s" STATUS=$?" r@ write-line throw s" if [ $STATUS -ne 0 ]; then" r@ write-line throw s" echo -e '\\x1b[31mInvalid\\x1b[0m'" r@ write-line throw @@ -369,9 +474,9 @@ s" rm -f /tmp/unsandbox_key_resp.json" r@ write-line throw else \ Extend mode - s" RESP=$(curl -s -X POST $PORTAL_BASE/keys/validate -H 'Content-Type: application/json' -H \"Authorization: Bearer $API_KEY\")" r@ write-line throw - s" PUBLIC_KEY=$(echo \"$RESP\" | jq -r '.public_key // \"N/A\"')" r@ write-line throw - s" xdg-open \"$PORTAL_BASE/keys/extend?pk=$PUBLIC_KEY\" 2>/dev/null" r@ write-line throw + s" RESP=$(curl -s -X POST $PORTAL_BASE/keys/validate -H 'Content-Type: application/json' -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" -d \"$BODY\")" r@ write-line throw + s" FETCHED_PUBLIC_KEY=$(echo \"$RESP\" | jq -r '.public_key // \"N/A\"')" r@ write-line throw + s" xdg-open \"$PORTAL_BASE/keys/extend?pk=$FETCHED_PUBLIC_KEY\" 2>/dev/null" r@ write-line throw then r> close-file throw diff --git a/un.fs b/un.fs index 3ca8786..b0d094b 100644 --- a/un.fs +++ b/un.fs @@ -44,6 +44,7 @@ open System open System.IO open System.Net open System.Text +open System.Security.Cryptography let apiBase = "https://api.unsandbox.com" let portalBase = "https://unsandbox.com" @@ -101,12 +102,19 @@ type Args = { mutable KeyExtend: bool } -let getApiKey (argsKey: string option) = - let key = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY") - if String.IsNullOrEmpty(key) then - eprintfn "%sError: UNSANDBOX_API_KEY not set%s" red reset - exit 1 - key +let getApiKeys (argsKey: string option) = + let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") + let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then + let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY") + if String.IsNullOrEmpty(legacyKey) then + eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset + exit 1 + (legacyKey, null) + else + (publicKey, secretKey) let detectLanguage (filename: string) = let dotIndex = filename.LastIndexOf('.') @@ -222,19 +230,35 @@ let parseJson (json: string) = result |> Seq.map (fun (k, v) -> k, v) |> Map.ofSeq -let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (apiKey: string) = +let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) = ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest request.Method <- method request.ContentType <- "application/json" - request.Headers.Add("Authorization", sprintf "Bearer %s" apiKey) request.Timeout <- 300000 + let body = match data with | Some d -> toJson (box d) | None -> "" + + // Add HMAC authentication headers if secretKey is provided + if not (String.IsNullOrEmpty(secretKey)) then + let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + let message = sprintf "%d:%s:%s:%s" timestamp method endpoint body + + use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)) + let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)) + let signature = BitConverter.ToString(hash).Replace("-", "").ToLower() + + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + request.Headers.Add("X-Timestamp", timestamp.ToString()) + request.Headers.Add("X-Signature", signature) + else + // Legacy API key authentication + request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey) + match data with | Some d -> - let json = toJson (box d) - let bytes = Encoding.UTF8.GetBytes(json) + let bytes = Encoding.UTF8.GetBytes(body) request.ContentLength <- int64 bytes.Length use stream = request.GetRequestStream() stream.Write(bytes, 0, bytes.Length) @@ -258,7 +282,7 @@ let apiRequest (endpoint: string) (method: string) (data: (string * obj) list op failwithf "HTTP error - %s" errorMsg let cmdExecute (args: Args) = - let apiKey = getApiKey args.ApiKey + let (publicKey, secretKey) = getApiKeys args.ApiKey let code = File.ReadAllText(args.SourceFile.Value) let language = detectLanguage args.SourceFile.Value @@ -286,7 +310,7 @@ let cmdExecute (args: Args) = if args.Vcpu > 0 then payload <- payload @ [("vcpu", box args.Vcpu)] - let result = apiRequest "/execute" "POST" (Some payload) apiKey + let result = apiRequest "/execute" "POST" (Some payload) publicKey secretKey match result.TryFind "stdout" with | Some stdout when not (String.IsNullOrEmpty(stdout.ToString())) -> @@ -307,14 +331,14 @@ let cmdExecute (args: Args) = exit exitCode let cmdSession (args: Args) = - let apiKey = getApiKey args.ApiKey + let (publicKey, secretKey) = getApiKeys args.ApiKey if args.SessionList then - let result = apiRequest "/sessions" "GET" None apiKey + let result = apiRequest "/sessions" "GET" None publicKey secretKey printfn "%-40s %-10s %-10s %s" "ID" "Shell" "Status" "Created" printfn "No sessions (list parsing not implemented)" elif args.SessionKill.IsSome then - let result = apiRequest (sprintf "/sessions/%s" args.SessionKill.Value) "DELETE" None apiKey + let result = apiRequest (sprintf "/sessions/%s" args.SessionKill.Value) "DELETE" None publicKey secretKey printfn "%sSession terminated: %s%s" green args.SessionKill.Value reset else let mutable payload = [("shell", box (match args.SessionShell with | Some s -> s | None -> "bash"))] @@ -324,7 +348,7 @@ let cmdSession (args: Args) = payload <- payload @ [("vcpu", box args.Vcpu)] printfn "%sCreating session...%s" yellow reset - let result = apiRequest "/sessions" "POST" (Some payload) apiKey + let result = apiRequest "/sessions" "POST" (Some payload) publicKey secretKey match result.TryFind "id" with | Some id -> printfn "%sSession created: %s%s" green (id.ToString()) reset | None -> printfn "%sSession created%s" green reset @@ -412,37 +436,37 @@ let cmdKey (args: Args) = exit 1 let cmdService (args: Args) = - let apiKey = getApiKey args.ApiKey + let (publicKey, secretKey) = getApiKeys args.ApiKey if args.ServiceList then - let result = apiRequest "/services" "GET" None apiKey + let result = apiRequest "/services" "GET" None publicKey secretKey printfn "%-20s %-15s %-10s %-15s %s" "ID" "Name" "Status" "Ports" "Domains" printfn "No services (list parsing not implemented)" elif args.ServiceInfo.IsSome then - let result = apiRequest (sprintf "/services/%s" args.ServiceInfo.Value) "GET" None apiKey + let result = apiRequest (sprintf "/services/%s" args.ServiceInfo.Value) "GET" None publicKey secretKey printfn "%s" (toJson (box result)) elif args.ServiceLogs.IsSome then - let result = apiRequest (sprintf "/services/%s/logs" args.ServiceLogs.Value) "GET" None apiKey + let result = apiRequest (sprintf "/services/%s/logs" args.ServiceLogs.Value) "GET" None publicKey secretKey match result.TryFind "logs" with | Some logs -> printfn "%s" (logs.ToString()) | None -> () elif args.ServiceTail.IsSome then - let result = apiRequest (sprintf "/services/%s/logs?lines=9000" args.ServiceTail.Value) "GET" None apiKey + let result = apiRequest (sprintf "/services/%s/logs?lines=9000" args.ServiceTail.Value) "GET" None publicKey secretKey match result.TryFind "logs" with | Some logs -> printfn "%s" (logs.ToString()) | None -> () elif args.ServiceSleep.IsSome then - let result = apiRequest (sprintf "/services/%s/sleep" args.ServiceSleep.Value) "POST" None apiKey + let result = apiRequest (sprintf "/services/%s/sleep" args.ServiceSleep.Value) "POST" None publicKey secretKey printfn "%sService sleeping: %s%s" green args.ServiceSleep.Value reset elif args.ServiceWake.IsSome then - let result = apiRequest (sprintf "/services/%s/wake" args.ServiceWake.Value) "POST" None apiKey + let result = apiRequest (sprintf "/services/%s/wake" args.ServiceWake.Value) "POST" None publicKey secretKey printfn "%sService waking: %s%s" green args.ServiceWake.Value reset elif args.ServiceDestroy.IsSome then - let result = apiRequest (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None apiKey + let result = apiRequest (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey printfn "%sService destroyed: %s%s" green args.ServiceDestroy.Value reset elif args.ServiceExecute.IsSome then let payload = [("command", box args.ServiceCommand.Value)] - let result = apiRequest (sprintf "/services/%s/execute" args.ServiceExecute.Value) "POST" (Some payload) apiKey + let result = apiRequest (sprintf "/services/%s/execute" args.ServiceExecute.Value) "POST" (Some payload) publicKey secretKey match result.TryFind "stdout" with | Some stdout when not (String.IsNullOrEmpty(stdout.ToString())) -> printf "%s%s%s" blue (stdout.ToString()) reset @@ -454,7 +478,7 @@ let cmdService (args: Args) = elif args.ServiceDumpBootstrap.IsSome then eprintfn "Fetching bootstrap script from %s..." args.ServiceDumpBootstrap.Value let payload = [("command", box "cat /tmp/bootstrap.sh")] - let result = apiRequest (sprintf "/services/%s/execute" args.ServiceDumpBootstrap.Value) "POST" (Some payload) apiKey + let result = apiRequest (sprintf "/services/%s/execute" args.ServiceDumpBootstrap.Value) "POST" (Some payload) publicKey secretKey match result.TryFind "stdout" with | Some bootstrap when not (String.IsNullOrEmpty(bootstrap.ToString())) -> @@ -485,7 +509,7 @@ let cmdService (args: Args) = if args.Vcpu > 0 then payload <- payload @ [("vcpu", box args.Vcpu)] - let result = apiRequest "/services" "POST" (Some payload) apiKey + let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey match result.TryFind "id" with | Some id -> printfn "%sService created: %s%s" green (id.ToString()) reset | None -> printfn "%sService created%s" green reset diff --git a/un.go b/un.go index c112810..2cd42ea 100644 --- a/un.go +++ b/un.go @@ -47,7 +47,10 @@ package main import ( "bytes" + "crypto/hmac" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -133,21 +136,38 @@ func detectLanguage(filename string) (string, error) { return "", fmt.Errorf("cannot detect language from extension") } -func getAPIKey(keyArg string) string { - if keyArg != "" { - return keyArg +func getAPIKeys(keyArg string) (string, string) { + publicKey := os.Getenv("UNSANDBOX_PUBLIC_KEY") + secretKey := os.Getenv("UNSANDBOX_SECRET_KEY") + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if publicKey == "" || secretKey == "" { + fallbackKey := keyArg + if fallbackKey == "" { + fallbackKey = os.Getenv("UNSANDBOX_API_KEY") + } + if fallbackKey == "" { + fmt.Fprintf(os.Stderr, "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)%s\n", Red, Reset) + os.Exit(1) + } + // Use fallback key as both public and secret for backwards compatibility + return fallbackKey, fallbackKey } - key := os.Getenv("UNSANDBOX_API_KEY") - if key == "" { - fmt.Fprintf(os.Stderr, "%sError: UNSANDBOX_API_KEY not set%s\n", Red, Reset) - os.Exit(1) - } - return key + + return publicKey, secretKey } -func apiRequest(endpoint, method string, data map[string]interface{}, apiKey string) map[string]interface{} { +func computeHMAC(secretKey, timestamp, method, path, body string) string { + message := fmt.Sprintf("%s:%s:%s:%s", timestamp, method, path, body) + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} + +func apiRequest(endpoint, method string, data map[string]interface{}, publicKey, secretKey string) map[string]interface{} { url := APIBase + endpoint var reqBody io.Reader + bodyStr := "" if data != nil { jsonData, err := json.Marshal(data) @@ -155,6 +175,7 @@ func apiRequest(endpoint, method string, data map[string]interface{}, apiKey str fmt.Fprintf(os.Stderr, "%sError marshaling JSON: %v%s\n", Red, err, Reset) os.Exit(1) } + bodyStr = string(jsonData) reqBody = bytes.NewBuffer(jsonData) } @@ -164,7 +185,13 @@ func apiRequest(endpoint, method string, data map[string]interface{}, apiKey str os.Exit(1) } - req.Header.Set("Authorization", "Bearer "+apiKey) + // HMAC authentication + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + signature := computeHMAC(secretKey, timestamp, method, endpoint, bodyStr) + + req.Header.Set("Authorization", "Bearer "+publicKey) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Signature", signature) req.Header.Set("Content-Type", "application/json") client := &http.Client{} @@ -195,7 +222,7 @@ func apiRequest(endpoint, method string, data map[string]interface{}, apiKey str return result } -func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, apiKey string) { +func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) { code, err := os.ReadFile(sourceFile) if err != nil { fmt.Fprintf(os.Stderr, "%sError reading file: %v%s\n", Red, err, Reset) @@ -254,7 +281,7 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo payload["vcpu"] = vcpu } - result := apiRequest("/execute", "POST", payload, apiKey) + result := apiRequest("/execute", "POST", payload, publicKey, secretKey) // Print output if stdout, ok := result["stdout"].(string); ok && stdout != "" { @@ -294,9 +321,9 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo os.Exit(exitCode) } -func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int, tmux, screen bool, apiKey string) { +func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int, tmux, screen bool, publicKey, secretKey string) { if sessionList != "" { - result := apiRequest("/sessions", "GET", nil, apiKey) + result := apiRequest("/sessions", "GET", nil, publicKey, secretKey) sessions := result["sessions"].([]interface{}) if len(sessions) == 0 { fmt.Println("No active sessions") @@ -312,7 +339,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int } if sessionKill != "" { - apiRequest("/sessions/"+sessionKill, "DELETE", nil, apiKey) + apiRequest("/sessions/"+sessionKill, "DELETE", nil, publicKey, secretKey) fmt.Printf("%sSession terminated: %s%s\n", Green, sessionKill, Reset) return } @@ -338,13 +365,13 @@ func cmdSession(sessionList, sessionKill, sessionShell, network string, vcpu int } fmt.Printf("%sCreating session...%s\n", Yellow, Reset) - result := apiRequest("/sessions", "POST", payload, apiKey) + result := apiRequest("/sessions", "POST", payload, publicKey, secretKey) fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset) } -func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, network string, vcpu int, apiKey string) { +func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, network string, vcpu int, publicKey, secretKey string) { if serviceList != "" { - result := apiRequest("/services", "GET", nil, apiKey) + result := apiRequest("/services", "GET", nil, publicKey, secretKey) services := result["services"].([]interface{}) if len(services) == 0 { fmt.Println("No services") @@ -376,45 +403,45 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } if serviceInfo != "" { - result := apiRequest("/services/"+serviceInfo, "GET", nil, apiKey) + result := apiRequest("/services/"+serviceInfo, "GET", nil, publicKey, secretKey) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) return } if serviceLogs != "" { - result := apiRequest("/services/"+serviceLogs+"/logs", "GET", nil, apiKey) + result := apiRequest("/services/"+serviceLogs+"/logs", "GET", nil, publicKey, secretKey) fmt.Print(result["logs"]) return } if serviceTail != "" { - result := apiRequest("/services/"+serviceTail+"/logs?lines=9000", "GET", nil, apiKey) + result := apiRequest("/services/"+serviceTail+"/logs?lines=9000", "GET", nil, publicKey, secretKey) fmt.Print(result["logs"]) return } if serviceSleep != "" { - apiRequest("/services/"+serviceSleep+"/sleep", "POST", nil, apiKey) + apiRequest("/services/"+serviceSleep+"/sleep", "POST", nil, publicKey, secretKey) fmt.Printf("%sService sleeping: %s%s\n", Green, serviceSleep, Reset) return } if serviceWake != "" { - apiRequest("/services/"+serviceWake+"/wake", "POST", nil, apiKey) + apiRequest("/services/"+serviceWake+"/wake", "POST", nil, publicKey, secretKey) fmt.Printf("%sService waking: %s%s\n", Green, serviceWake, Reset) return } if serviceDestroy != "" { - apiRequest("/services/"+serviceDestroy, "DELETE", nil, apiKey) + apiRequest("/services/"+serviceDestroy, "DELETE", nil, publicKey, secretKey) fmt.Printf("%sService destroyed: %s%s\n", Green, serviceDestroy, Reset) return } if serviceExecute != "" { payload := map[string]interface{}{"command": serviceCommand} - result := apiRequest("/services/"+serviceExecute+"/execute", "POST", payload, apiKey) + result := apiRequest("/services/"+serviceExecute+"/execute", "POST", payload, publicKey, secretKey) if stdout, ok := result["stdout"].(string); ok { fmt.Printf("%s%s%s", Blue, stdout, Reset) } @@ -427,7 +454,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB if serviceDumpBootstrap != "" { fmt.Fprintf(os.Stderr, "Fetching bootstrap script from %s...\n", serviceDumpBootstrap) payload := map[string]interface{}{"command": "cat /tmp/bootstrap.sh"} - result := apiRequest("/services/"+serviceDumpBootstrap+"/execute", "POST", payload, apiKey) + result := apiRequest("/services/"+serviceDumpBootstrap+"/execute", "POST", payload, publicKey, secretKey) if bootstrap, ok := result["stdout"].(string); ok && bootstrap != "" { if serviceDumpFile != "" { @@ -482,7 +509,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB payload["vcpu"] = vcpu } - result := apiRequest("/services", "POST", payload, apiKey) + result := apiRequest("/services", "POST", payload, publicKey, secretKey) fmt.Printf("%sService created: %s%s\n", Green, result["id"], Reset) fmt.Printf("Name: %s\n", result["name"]) if url, ok := result["url"]; ok { @@ -524,7 +551,7 @@ func formatDuration(d time.Duration) string { } } -func validateKey(apiKey string, extend bool) { +func validateKey(publicKey, secretKey string, extend bool) { url := PortalBase + "/keys/validate" reqBody := bytes.NewBuffer(nil) @@ -534,7 +561,13 @@ func validateKey(apiKey string, extend bool) { os.Exit(1) } - req.Header.Set("Authorization", "Bearer "+apiKey) + // HMAC authentication + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + signature := computeHMAC(secretKey, timestamp, "POST", "/keys/validate", "") + + req.Header.Set("Authorization", "Bearer "+publicKey) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Signature", signature) req.Header.Set("Content-Type", "application/json") client := &http.Client{} @@ -702,7 +735,7 @@ func main() { switch os.Args[1] { case "session": sessionCmd.Parse(os.Args[2:]) - key := getAPIKey(*sessionKey) + publicKey, secretKey := getAPIKeys(*sessionKey) net := *sessionNetwork if net == "" { net = *network @@ -711,12 +744,12 @@ func main() { if vc == 0 { vc = *vcpu } - cmdSession(*sessionList, *sessionKill, *sessionShell, net, vc, *sessionTmux, *sessionScreen, key) + cmdSession(*sessionList, *sessionKill, *sessionShell, net, vc, *sessionTmux, *sessionScreen, publicKey, secretKey) return case "service": serviceCmd.Parse(os.Args[2:]) - key := getAPIKey(*serviceKey) + publicKey, secretKey := getAPIKeys(*serviceKey) net := *serviceNetwork if net == "" { net = *network @@ -725,13 +758,13 @@ func main() { if vc == 0 { vc = *vcpu } - cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, net, vc, key) + cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, net, vc, publicKey, secretKey) return case "key": keyCmd.Parse(os.Args[2:]) - key := getAPIKey(*keyKey) - validateKey(key, *keyExtend) + publicKey, secretKey := getAPIKeys(*keyKey) + validateKey(publicKey, secretKey, *keyExtend) return } } @@ -746,6 +779,6 @@ func main() { } sourceFile := flag.Arg(0) - key := getAPIKey(*apiKey) - cmdExecute(sourceFile, envs, files, *artifacts, *outputDir, *network, *vcpu, key) + publicKey, secretKey := getAPIKeys(*apiKey) + cmdExecute(sourceFile, envs, files, *artifacts, *outputDir, *network, *vcpu, publicKey, secretKey) } diff --git a/un.groovy b/un.groovy index 6f22c42..3d1e889 100644 --- a/un.groovy +++ b/un.groovy @@ -92,13 +92,24 @@ class Args { Boolean keyExtend = false } -def getApiKey(argsKey) { - def key = argsKey ?: System.getenv('UNSANDBOX_API_KEY') - if (!key) { - System.err.println("${RED}Error: UNSANDBOX_API_KEY not set${RESET}") - System.exit(1) +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +def getApiKeys(argsKey) { + def publicKey = System.getenv('UNSANDBOX_PUBLIC_KEY') + def secretKey = System.getenv('UNSANDBOX_SECRET_KEY') + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (!publicKey || !secretKey) { + def legacyKey = argsKey ?: System.getenv('UNSANDBOX_API_KEY') + if (!legacyKey) { + System.err.println("${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}") + System.exit(1) + } + return [legacyKey, null] } - return key + + return [publicKey, secretKey] } def detectLanguage(filename) { @@ -116,16 +127,33 @@ def detectLanguage(filename) { return language } -def apiRequest(endpoint, method, data, apiKey) { +def apiRequest(endpoint, method, data, publicKey, secretKey) { def tempFile = File.createTempFile('un_request_', '.json') try { + def body = data ?: "" if (data) { tempFile.text = data } def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", - '-H', 'Content-Type: application/json', - '-H', "Authorization: Bearer ${apiKey}"] + '-H', 'Content-Type: application/json'] + + // Add HMAC authentication headers if secretKey is provided + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:${method}:${endpoint}:${body}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + // Legacy API key authentication + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } if (data) { curlCmd += ['-d', "@${tempFile.absolutePath}"] @@ -147,7 +175,7 @@ def apiRequest(endpoint, method, data, apiKey) { } def cmdExecute(args) { - def apiKey = getApiKey(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey) def file = new File(args.sourceFile) if (!file.exists()) { System.err.println("${RED}Error: File not found: ${args.sourceFile}${RESET}") @@ -203,7 +231,7 @@ def cmdExecute(args) { json += '}' - def output = apiRequest('/execute', 'POST', json, apiKey) + def output = apiRequest('/execute', 'POST', json, publicKey, secretKey) def stdoutMatch = output =~ /"stdout":"((?:[^"\\]|\\.)*)"/ def stderrMatch = output =~ /"stderr":"((?:[^"\\]|\\.)*)"/ @@ -245,17 +273,17 @@ def cmdExecute(args) { } def cmdSession(args) { - def apiKey = getApiKey(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.sessionList) { - def output = apiRequest('/sessions', 'GET', null, apiKey) + def output = apiRequest('/sessions', 'GET', null, publicKey, secretKey) println("%-40s %-10s %-10s %s".format("ID", "Shell", "Status", "Created")) println("No sessions (list parsing not implemented)") return } if (args.sessionKill) { - apiRequest("/sessions/${args.sessionKill}", 'DELETE', null, apiKey) + apiRequest("/sessions/${args.sessionKill}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Session terminated: ${args.sessionKill}${RESET}") return } @@ -270,7 +298,7 @@ def cmdSession(args) { json += '}' println("${YELLOW}Creating session...${RESET}") - def output = apiRequest('/sessions', 'POST', json, apiKey) + def output = apiRequest('/sessions', 'POST', json, publicKey, secretKey) def idMatch = output =~ /"id":"([^"]+)"/ if (idMatch.find()) { println("${GREEN}Session created: ${idMatch.group(1)}${RESET}") @@ -296,12 +324,28 @@ def openBrowser(url) { } def cmdKey(args) { - def apiKey = getApiKey(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey) def curlCmd = ['curl', '-s', '-X', 'POST', "${PORTAL_BASE}/keys/validate", - '-H', 'Content-Type: application/json', - '-H', "Authorization: Bearer ${apiKey}", - '-d', '{}'] + '-H', 'Content-Type: application/json'] + + // Add HMAC authentication headers if secretKey is provided + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:POST:/keys/validate:{}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + curlCmd += ['-d', '{}'] def proc = curlCmd.execute() def output = proc.text @@ -361,23 +405,23 @@ def cmdKey(args) { } def cmdService(args) { - def apiKey = getApiKey(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.serviceList) { - def output = apiRequest('/services', 'GET', null, apiKey) + def output = apiRequest('/services', 'GET', null, publicKey, secretKey) println("%-20s %-15s %-10s %-15s %s".format("ID", "Name", "Status", "Ports", "Domains")) println("No services (list parsing not implemented)") return } if (args.serviceInfo) { - def output = apiRequest("/services/${args.serviceInfo}", 'GET', null, apiKey) + def output = apiRequest("/services/${args.serviceInfo}", 'GET', null, publicKey, secretKey) println(output) return } if (args.serviceLogs) { - def output = apiRequest("/services/${args.serviceLogs}/logs", 'GET', null, apiKey) + def output = apiRequest("/services/${args.serviceLogs}/logs", 'GET', null, publicKey, secretKey) def logsMatch = output =~ /"logs":"((?:[^"\\]|\\.)*)"/ if (logsMatch.find()) { println(logsMatch.group(1).replace('\\n', '\n')) @@ -386,7 +430,7 @@ def cmdService(args) { } if (args.serviceTail) { - def output = apiRequest("/services/${args.serviceTail}/logs?lines=9000", 'GET', null, apiKey) + def output = apiRequest("/services/${args.serviceTail}/logs?lines=9000", 'GET', null, publicKey, secretKey) def logsMatch = output =~ /"logs":"((?:[^"\\]|\\.)*)"/ if (logsMatch.find()) { println(logsMatch.group(1).replace('\\n', '\n')) @@ -395,26 +439,26 @@ def cmdService(args) { } if (args.serviceSleep) { - apiRequest("/services/${args.serviceSleep}/sleep", 'POST', null, apiKey) + apiRequest("/services/${args.serviceSleep}/sleep", 'POST', null, publicKey, secretKey) println("${GREEN}Service sleeping: ${args.serviceSleep}${RESET}") return } if (args.serviceWake) { - apiRequest("/services/${args.serviceWake}/wake", 'POST', null, apiKey) + apiRequest("/services/${args.serviceWake}/wake", 'POST', null, publicKey, secretKey) println("${GREEN}Service waking: ${args.serviceWake}${RESET}") return } if (args.serviceDestroy) { - apiRequest("/services/${args.serviceDestroy}", 'DELETE', null, apiKey) + apiRequest("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") return } if (args.serviceExecute) { def json = """{"command":"${args.serviceCommand}"}""" - def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', json, apiKey) + def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', json, publicKey, secretKey) def stdoutMatch = output =~ /"stdout":"((?:[^"\\\\]|\\\\.)*)"/ def stderrMatch = output =~ /"stderr":"((?:[^"\\\\]|\\\\.)*)"/ @@ -441,7 +485,7 @@ def cmdService(args) { if (args.serviceDumpBootstrap) { System.err.println("Fetching bootstrap script from ${args.serviceDumpBootstrap}...") def json = """{"command":"cat /tmp/bootstrap.sh"}""" - def output = apiRequest("/services/${args.serviceDumpBootstrap}/execute", 'POST', json, apiKey) + def output = apiRequest("/services/${args.serviceDumpBootstrap}/execute", 'POST', json, publicKey, secretKey) def stdoutMatch = output =~ /"stdout":"((?:[^"\\\\]|\\\\.)*)"/ if (stdoutMatch.find()) { @@ -491,7 +535,7 @@ def cmdService(args) { } json += '}' - def output = apiRequest('/services', 'POST', json, apiKey) + def output = apiRequest('/services', 'POST', json, publicKey, secretKey) def idMatch = output =~ /"id":"([^"]+)"/ if (idMatch.find()) { println("${GREEN}Service created: ${idMatch.group(1)}${RESET}") diff --git a/un.hs b/un.hs index 8a98d80..3aa5127 100644 --- a/un.hs +++ b/un.hs @@ -62,11 +62,15 @@ import System.Process (readProcessWithExitCode) import System.IO (hPutStrLn, stderr) import System.Directory (createDirectoryIfMissing, setPermissions, getPermissions, setOwnerExecutable) import Data.List (isPrefixOf, intercalate) -import Data.Char (isDigit) +import Data.Char (isDigit, ord) import Text.Printf (printf) import Control.Monad (when, unless, forM_) import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Base64 as B64 +import Crypto.Hash.SHA256 (hmac) +import Numeric (showHex) +import Data.Time.Clock.POSIX (getPOSIXTime) -- API constants apiBase :: String @@ -382,39 +386,77 @@ serviceCommand opts = do -- HTTP helpers using curl curlPost :: String -> String -> String -> IO (ExitCode, String, String) curlPost apiKey url body = do + (publicKey, secretKey) <- getApiKeys + -- Extract path from URL + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "POST" path body (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" - [ "-s", "-X", "POST" - , url - , "-H", "Content-Type: application/json" - , "-H", "Authorization: Bearer " ++ apiKey - , "-d", body - ] "" + ([ "-s", "-X", "POST" + , url + , "-H", "Content-Type: application/json" + ] ++ authHeaders ++ ["-d", body]) "" return (exitCode, stdout, stderr) curlGet :: String -> String -> IO (ExitCode, String, String) -curlGet apiKey url = +curlGet apiKey url = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "GET" path "" readProcessWithExitCode "curl" - [ "-s", url - , "-H", "Authorization: Bearer " ++ apiKey - ] "" + ([ "-s", url ] ++ authHeaders) "" curlDelete :: String -> String -> IO (ExitCode, String, String) -curlDelete apiKey url = +curlDelete apiKey url = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length "https://api.unsandbox.com") url + authHeaders <- buildAuthHeaders publicKey secretKey "DELETE" path "" readProcessWithExitCode "curl" - [ "-s", "-X", "DELETE" - , url - , "-H", "Authorization: Bearer " ++ apiKey - ] "" + ([ "-s", "-X", "DELETE", url ] ++ authHeaders) "" + +-- Get API keys from environment +getApiKeys :: IO (String, Maybe String) +getApiKeys = do + publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" + secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" + apiKey <- lookupEnv "UNSANDBOX_API_KEY" + case (publicKey, secretKey, apiKey) of + (Just pk, Just sk, _) -> return (pk, Just sk) + (_, _, Just ak) -> return (ak, Nothing) + _ -> do + hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)" + exitFailure --- Get API key from environment getApiKey :: IO String getApiKey = do - maybeKey <- lookupEnv "UNSANDBOX_API_KEY" - case maybeKey of - Nothing -> do - hPutStrLn stderr "Error: UNSANDBOX_API_KEY not set" - exitFailure - Just key -> return key + (publicKey, _) <- getApiKeys + return publicKey + +-- HMAC-SHA256 +hmacSha256 :: String -> String -> String +hmacSha256 secret message = + let secretBS = BSC.pack secret + messageBS = BSC.pack message + mac = hmac secretBS messageBS + in concatMap (printf "%02x") (BS.unpack mac) + +makeSignature :: String -> String -> String -> String -> String -> String +makeSignature secretKey timestamp method path body = + let message = timestamp ++ ":" ++ method ++ ":" ++ path ++ ":" ++ body + in hmacSha256 secretKey message + +buildAuthHeaders :: String -> Maybe String -> String -> String -> String -> IO [String] +buildAuthHeaders publicKey maybeSecretKey method path body = + case maybeSecretKey of + Just secretKey -> do + now <- getPOSIXTime + let timestamp = show (floor now :: Integer) + let signature = makeSignature secretKey timestamp method path body + return [ "-H", "Authorization: Bearer " ++ publicKey + , "-H", "X-Timestamp: " ++ timestamp + , "-H", "X-Signature: " ++ signature + ] + Nothing -> + return ["-H", "Authorization: Bearer " ++ publicKey] -- Parse exit code from JSON response parseExitCode :: String -> Int @@ -565,11 +607,12 @@ extendKey apiKey = do -- HTTP helper for portal API curlPostPortal :: String -> String -> String -> IO (ExitCode, String, String) curlPostPortal apiKey url body = do + (publicKey, secretKey) <- getApiKeys + let path = drop (length portalBase) url + authHeaders <- buildAuthHeaders publicKey secretKey "POST" path body (exitCode, stdout, stderr) <- readProcessWithExitCode "curl" - [ "-s", "-X", "POST" - , url - , "-H", "Content-Type: application/json" - , "-H", "Authorization: Bearer " ++ apiKey - , "-d", body - ] "" + ([ "-s", "-X", "POST" + , url + , "-H", "Content-Type: application/json" + ] ++ authHeaders ++ ["-d", body]) "" return (exitCode, stdout, stderr) diff --git a/un.jl b/un.jl index 8b834e5..35bcbf9 100755 --- a/un.jl +++ b/un.jl @@ -42,6 +42,7 @@ using JSON using Base64 using ArgParse using Printf +using SHA # Extension to language mapping const EXT_MAP = Dict( @@ -75,19 +76,54 @@ function detect_language(filename::String)::String return get(EXT_MAP, ext, "unknown") end -function get_api_key(args_key=nothing)::String - key = something(args_key, get(ENV, "UNSANDBOX_API_KEY", "")) - if isempty(key) - println(stderr, "$(RED)Error: UNSANDBOX_API_KEY not set$(RESET)") +function get_api_keys(args_key=nothing)::Tuple{String,String} + # Try new-style keys first + public_key = something(args_key, get(ENV, "UNSANDBOX_PUBLIC_KEY", "")) + secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") + + # Fall back to old-style single key for backwards compatibility + if isempty(public_key) + old_key = get(ENV, "UNSANDBOX_API_KEY", "") + if isempty(old_key) + println(stderr, "$(RED)Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set$(RESET)") + exit(1) + end + # Old-style: use same key for both public and secret + return (old_key, old_key) + end + + if isempty(secret_key) + println(stderr, "$(RED)Error: UNSANDBOX_SECRET_KEY not set$(RESET)") exit(1) end - return key + + return (public_key, secret_key) end -function api_request(endpoint::String, api_key::String; method="GET", data=nothing) +function hmac_sha256_hex(key::String, message::String)::String + h = hmac_sha256(Vector{UInt8}(key), Vector{UInt8}(message)) + return bytes2hex(h) +end + +function compute_signature(secret_key::String, timestamp::Int64, method::String, path::String, body::String)::String + message = "$(timestamp):$(method):$(path):$(body)" + return hmac_sha256_hex(secret_key, message) +end + +function api_request(endpoint::String, public_key::String, secret_key::String; method="GET", data=nothing) url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, method, endpoint, body) + headers = [ - "Authorization" => "Bearer $api_key", + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, "Content-Type" => "application/json" ] @@ -95,7 +131,6 @@ function api_request(endpoint::String, api_key::String; method="GET", data=nothi if method == "GET" response = HTTP.get(url, headers, readtimeout=300) elseif method == "POST" - body = data !== nothing ? JSON.json(data) : "" response = HTTP.post(url, headers, body, readtimeout=300) elseif method == "DELETE" response = HTTP.delete(url, headers, readtimeout=300) @@ -115,7 +150,7 @@ function api_request(endpoint::String, api_key::String; method="GET", data=nothi end function cmd_execute(args) - api_key = get_api_key(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]) filename = args["source_file"] if !isfile(filename) @@ -176,7 +211,7 @@ function cmd_execute(args) end # Execute - result = api_request("/execute", api_key, method="POST", data=payload) + result = api_request("/execute", public_key, secret_key, method="POST", data=payload) # Print output if haskey(result, "stdout") && !isempty(result["stdout"]) @@ -205,10 +240,10 @@ function cmd_execute(args) end function cmd_session(args) - api_key = get_api_key(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]) if args["list"] - result = api_request("/sessions", api_key) + result = api_request("/sessions", public_key, secret_key) sessions = get(result, "sessions", []) if isempty(sessions) println("No active sessions") @@ -226,7 +261,7 @@ function cmd_session(args) end if args["kill"] !== nothing - api_request("/sessions/$(args["kill"])", api_key, method="DELETE") + api_request("/sessions/$(args["kill"])", public_key, secret_key, method="DELETE") println("$(GREEN)Session terminated: $(args["kill"])$(RESET)") return end @@ -236,10 +271,10 @@ function cmd_session(args) end function cmd_service(args) - api_key = get_api_key(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]) if args["list"] - result = api_request("/services", api_key) + result = api_request("/services", public_key, secret_key) services = get(result, "services", []) if isempty(services) println("No services") @@ -259,31 +294,31 @@ function cmd_service(args) end if args["info"] !== nothing - result = api_request("/services/$(args["info"])", api_key) + result = api_request("/services/$(args["info"])", public_key, secret_key) println(JSON.json(result, 2)) return end if args["logs"] !== nothing - result = api_request("/services/$(args["logs"])/logs", api_key) + result = api_request("/services/$(args["logs"])/logs", public_key, secret_key) println(get(result, "logs", "")) return end if args["sleep"] !== nothing - api_request("/services/$(args["sleep"])/sleep", api_key, method="POST") + api_request("/services/$(args["sleep"])/sleep", public_key, secret_key, method="POST") println("$(GREEN)Service sleeping: $(args["sleep"])$(RESET)") return end if args["wake"] !== nothing - api_request("/services/$(args["wake"])/wake", api_key, method="POST") + api_request("/services/$(args["wake"])/wake", public_key, secret_key, method="POST") println("$(GREEN)Service waking: $(args["wake"])$(RESET)") return end if args["destroy"] !== nothing - api_request("/services/$(args["destroy"])", api_key, method="DELETE") + api_request("/services/$(args["destroy"])", public_key, secret_key, method="DELETE") println("$(GREEN)Service destroyed: $(args["destroy"])$(RESET)") return end @@ -291,7 +326,7 @@ function cmd_service(args) if args["dump-bootstrap"] !== nothing println(stderr, "Fetching bootstrap script from $(args["dump-bootstrap"])...") payload = Dict("command" => "cat /tmp/bootstrap.sh") - result = api_request("/services/$(args["dump-bootstrap"])/execute", api_key, method="POST", data=payload) + result = api_request("/services/$(args["dump-bootstrap"])/execute", public_key, secret_key, method="POST", data=payload) if haskey(result, "stdout") && !isempty(result["stdout"]) bootstrap = result["stdout"] @@ -351,7 +386,7 @@ function cmd_service(args) payload["vcpu"] = args["vcpu"] end - result = api_request("/services", api_key, method="POST", data=payload) + result = api_request("/services", public_key, secret_key, method="POST", data=payload) println("$(GREEN)Service created: $(get(result, "id", "N/A"))$(RESET)") println("Name: $(get(result, "name", "N/A"))") if haskey(result, "url") @@ -452,7 +487,9 @@ function validate_key(api_key::String) end function cmd_key(args) - api_key = get_api_key(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]) + # For portal validation, we still use public_key as bearer token + api_key = public_key # Handle --extend flag if args["extend"] diff --git a/un.js b/un.js index 4e223a8..0eaf9b9 100644 --- a/un.js +++ b/un.js @@ -55,6 +55,7 @@ const fs = require('fs'); const https = require('https'); const path = require('path'); const { exec } = require('child_process'); +const crypto = require('crypto'); const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; @@ -80,13 +81,25 @@ const EXT_MAP = { ".tcl": "tcl", ".raku": "raku", ".m": "objc", }; -function getApiKey(argsKey) { - const key = argsKey || process.env.UNSANDBOX_API_KEY; - if (!key) { - console.error(`${RED}Error: UNSANDBOX_API_KEY not set${RESET}`); - process.exit(1); +function getApiKeys(argsKey) { + // Try new split key format first + let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; + let secretKey = process.env.UNSANDBOX_SECRET_KEY; + + // Fall back to old single key format for backwards compatibility + if (!publicKey || !secretKey) { + const oldKey = argsKey || process.env.UNSANDBOX_API_KEY; + if (oldKey) { + publicKey = oldKey; + secretKey = oldKey; + } else { + console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); + console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); + process.exit(1); + } } - return key; + + return { publicKey, secretKey }; } function detectLanguage(filename) { @@ -111,32 +124,45 @@ function detectLanguage(filename) { return lang; } -function apiRequest(endpoint, method = "GET", data = null, apiKey = null) { +function apiRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) { return new Promise((resolve, reject) => { const url = new URL(API_BASE + endpoint); + + // Prepare body + const body = data ? JSON.stringify(data) : ""; + + // Generate HMAC signature + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`; + const signature = crypto.createHmac('sha256', secretKey) + .update(signatureInput) + .digest('hex'); + const options = { hostname: url.hostname, path: url.pathname + url.search, method: method, headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, 'Content-Type': 'application/json' }, timeout: 300000 }; const req = https.request(options, (res) => { - let body = ''; - res.on('data', chunk => body += chunk); + let responseBody = ''; + res.on('data', chunk => responseBody += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { - resolve(JSON.parse(body)); + resolve(JSON.parse(responseBody)); } catch (e) { - resolve(body); + resolve(responseBody); } } else { - console.error(`${RED}Error: HTTP ${res.statusCode} - ${body}${RESET}`); + console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`); process.exit(1); } }); @@ -148,42 +174,55 @@ function apiRequest(endpoint, method = "GET", data = null, apiKey = null) { }); if (data) { - req.write(JSON.stringify(data)); + req.write(body); } req.end(); }); } -function portalRequest(endpoint, method = "GET", data = null, apiKey = null) { +function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) { return new Promise((resolve, reject) => { const url = new URL(PORTAL_BASE + endpoint); + + // Prepare body + const body = data ? JSON.stringify(data) : ""; + + // Generate HMAC signature + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`; + const signature = crypto.createHmac('sha256', secretKey) + .update(signatureInput) + .digest('hex'); + const options = { hostname: url.hostname, path: url.pathname + url.search, method: method, headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, 'Content-Type': 'application/json' }, timeout: 30000 }; const req = https.request(options, (res) => { - let body = ''; - res.on('data', chunk => body += chunk); + let responseBody = ''; + res.on('data', chunk => responseBody += chunk); res.on('end', () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { - resolve(JSON.parse(body)); + resolve(JSON.parse(responseBody)); } catch (e) { - resolve(body); + resolve(responseBody); } } else { try { - const errorBody = JSON.parse(body); - resolve({ error: errorBody.error || body, status: res.statusCode }); + const errorBody = JSON.parse(responseBody); + resolve({ error: errorBody.error || responseBody, status: res.statusCode }); } catch (e) { - resolve({ error: body, status: res.statusCode }); + resolve({ error: responseBody, status: res.statusCode }); } } }); @@ -194,7 +233,7 @@ function portalRequest(endpoint, method = "GET", data = null, apiKey = null) { }); if (data) { - req.write(JSON.stringify(data)); + req.write(body); } req.end(); }); @@ -220,9 +259,9 @@ function openBrowser(url) { }); } -async function validateKey(apiKey, shouldExtend = false) { +async function validateKey(publicKey, secretKey, shouldExtend = false) { try { - const result = await portalRequest("/keys/validate", "POST", {}, apiKey); + const result = await portalRequest("/keys/validate", "POST", {}, publicKey, secretKey); // Handle --extend flag first if (shouldExtend) { @@ -265,12 +304,12 @@ async function validateKey(apiKey, shouldExtend = false) { } async function cmdKey(args) { - const apiKey = getApiKey(args.apiKey); - await validateKey(apiKey, args.extend); + const { publicKey, secretKey } = getApiKeys(args.apiKey); + await validateKey(publicKey, secretKey, args.extend); } async function cmdExecute(args) { - const apiKey = getApiKey(args.apiKey); + const { publicKey, secretKey } = getApiKeys(args.apiKey); let code; try { @@ -312,7 +351,7 @@ async function cmdExecute(args) { if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; - const result = await apiRequest("/execute", "POST", payload, apiKey); + const result = await apiRequest("/execute", "POST", payload, publicKey, secretKey); if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); @@ -334,10 +373,10 @@ async function cmdExecute(args) { } async function cmdSession(args) { - const apiKey = getApiKey(args.apiKey); + const { publicKey, secretKey } = getApiKeys(args.apiKey); if (args.list) { - const result = await apiRequest("/sessions", "GET", null, apiKey); + const result = await apiRequest("/sessions", "GET", null, publicKey, secretKey); const sessions = result.sessions || []; if (sessions.length === 0) { console.log("No active sessions"); @@ -351,7 +390,7 @@ async function cmdSession(args) { } if (args.kill) { - await apiRequest(`/sessions/${args.kill}`, "DELETE", null, apiKey); + await apiRequest(`/sessions/${args.kill}`, "DELETE", null, publicKey, secretKey); console.log(`${GREEN}Session terminated: ${args.kill}${RESET}`); return; } @@ -370,16 +409,16 @@ async function cmdSession(args) { if (args.audit) payload.audit = true; console.log(`${YELLOW}Creating session...${RESET}`); - const result = await apiRequest("/sessions", "POST", payload, apiKey); + const result = await apiRequest("/sessions", "POST", payload, publicKey, secretKey); console.log(`${GREEN}Session created: ${result.id || 'N/A'}${RESET}`); console.log(`${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}`); } async function cmdService(args) { - const apiKey = getApiKey(args.apiKey); + const { publicKey, secretKey } = getApiKeys(args.apiKey); if (args.list) { - const result = await apiRequest("/services", "GET", null, apiKey); + const result = await apiRequest("/services", "GET", null, publicKey, secretKey); const services = result.services || []; if (services.length === 0) { console.log("No services"); @@ -395,44 +434,44 @@ async function cmdService(args) { } if (args.info) { - const result = await apiRequest(`/services/${args.info}`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.info}`, "GET", null, publicKey, secretKey); console.log(JSON.stringify(result, null, 2)); return; } if (args.logs) { - const result = await apiRequest(`/services/${args.logs}/logs`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.logs}/logs`, "GET", null, publicKey, secretKey); console.log(result.logs || ""); return; } if (args.tail) { - const result = await apiRequest(`/services/${args.tail}/logs?lines=9000`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.tail}/logs?lines=9000`, "GET", null, publicKey, secretKey); console.log(result.logs || ""); return; } if (args.sleep) { - await apiRequest(`/services/${args.sleep}/sleep`, "POST", null, apiKey); + await apiRequest(`/services/${args.sleep}/sleep`, "POST", null, publicKey, secretKey); console.log(`${GREEN}Service sleeping: ${args.sleep}${RESET}`); return; } if (args.wake) { - await apiRequest(`/services/${args.wake}/wake`, "POST", null, apiKey); + await apiRequest(`/services/${args.wake}/wake`, "POST", null, publicKey, secretKey); console.log(`${GREEN}Service waking: ${args.wake}${RESET}`); return; } if (args.destroy) { - await apiRequest(`/services/${args.destroy}`, "DELETE", null, apiKey); + await apiRequest(`/services/${args.destroy}`, "DELETE", null, publicKey, secretKey); console.log(`${GREEN}Service destroyed: ${args.destroy}${RESET}`); return; } if (args.execute) { const payload = { command: args.command }; - const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, apiKey); + const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, publicKey, secretKey); if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); return; @@ -441,7 +480,7 @@ async function cmdService(args) { if (args.dumpBootstrap) { console.error(`Fetching bootstrap script from ${args.dumpBootstrap}...`); const payload = { command: "cat /tmp/bootstrap.sh" }; - const result = await apiRequest(`/services/${args.dumpBootstrap}/execute`, "POST", payload, apiKey); + const result = await apiRequest(`/services/${args.dumpBootstrap}/execute`, "POST", payload, publicKey, secretKey); if (result.stdout) { const bootstrap = result.stdout; @@ -481,7 +520,7 @@ async function cmdService(args) { if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; - const result = await apiRequest("/services", "POST", payload, apiKey); + const result = await apiRequest("/services", "POST", payload, publicKey, secretKey); console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`); console.log(`Name: ${result.name || 'N/A'}`); if (result.url) console.log(`URL: ${result.url}`); diff --git a/un.kt b/un.kt index ce74c39..60ebd72 100644 --- a/un.kt +++ b/un.kt @@ -45,6 +45,8 @@ import java.net.HttpURLConnection import java.net.URL import java.util.Base64 import kotlin.system.exitProcess +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec val API_BASE = "https://api.unsandbox.com" val PORTAL_BASE = "https://unsandbox.com" @@ -123,7 +125,7 @@ fun main(args: Array) { } fun cmdExecute(args: Args) { - val apiKey = getApiKey(args.apiKey) + val (publicKey, secretKey) = getApiKeys(args.apiKey) val code = File(args.sourceFile!!).readText() val language = detectLanguage(args.sourceFile!!) @@ -167,7 +169,7 @@ fun cmdExecute(args: Args) { payload["vcpu"] = args.vcpu } - val result = apiRequest("/execute", "POST", payload, apiKey) + val result = apiRequest("/execute", "POST", payload, publicKey, secretKey) val stdout = result["stdout"] as? String val stderr = result["stderr"] as? String @@ -198,10 +200,10 @@ fun cmdExecute(args: Args) { } fun cmdSession(args: Args) { - val apiKey = getApiKey(args.apiKey) + val (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.sessionList) { - val result = apiRequest("/sessions", "GET", null, apiKey) + val result = apiRequest("/sessions", "GET", null, publicKey, secretKey) @Suppress("UNCHECKED_CAST") val sessions = result["sessions"] as? List> if (sessions.isNullOrEmpty()) { @@ -221,7 +223,7 @@ fun cmdSession(args: Args) { } if (args.sessionKill != null) { - apiRequest("/sessions/${args.sessionKill}", "DELETE", null, apiKey) + apiRequest("/sessions/${args.sessionKill}", "DELETE", null, publicKey, secretKey) println("${GREEN}Session terminated: ${args.sessionKill}${RESET}") return } @@ -237,16 +239,16 @@ fun cmdSession(args: Args) { } println("${YELLOW}Creating session...${RESET}") - val result = apiRequest("/sessions", "POST", payload, apiKey) + val result = apiRequest("/sessions", "POST", payload, publicKey, secretKey) println("${GREEN}Session created: ${result["id"] ?: "N/A"}${RESET}") println("${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}") } fun cmdService(args: Args) { - val apiKey = getApiKey(args.apiKey) + val (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.serviceList) { - val result = apiRequest("/services", "GET", null, apiKey) + val result = apiRequest("/services", "GET", null, publicKey, secretKey) @Suppress("UNCHECKED_CAST") val services = result["services"] as? List> if (services.isNullOrEmpty()) { @@ -270,44 +272,44 @@ fun cmdService(args: Args) { } if (args.serviceInfo != null) { - val result = apiRequest("/services/${args.serviceInfo}", "GET", null, apiKey) + val result = apiRequest("/services/${args.serviceInfo}", "GET", null, publicKey, secretKey) println(toJson(result)) return } if (args.serviceLogs != null) { - val result = apiRequest("/services/${args.serviceLogs}/logs", "GET", null, apiKey) + val result = apiRequest("/services/${args.serviceLogs}/logs", "GET", null, publicKey, secretKey) println(result["logs"] ?: "") return } if (args.serviceTail != null) { - val result = apiRequest("/services/${args.serviceTail}/logs?lines=9000", "GET", null, apiKey) + val result = apiRequest("/services/${args.serviceTail}/logs?lines=9000", "GET", null, publicKey, secretKey) println(result["logs"] ?: "") return } if (args.serviceSleep != null) { - apiRequest("/services/${args.serviceSleep}/sleep", "POST", null, apiKey) + apiRequest("/services/${args.serviceSleep}/sleep", "POST", null, publicKey, secretKey) println("${GREEN}Service sleeping: ${args.serviceSleep}${RESET}") return } if (args.serviceWake != null) { - apiRequest("/services/${args.serviceWake}/wake", "POST", null, apiKey) + apiRequest("/services/${args.serviceWake}/wake", "POST", null, publicKey, secretKey) println("${GREEN}Service waking: ${args.serviceWake}${RESET}") return } if (args.serviceDestroy != null) { - apiRequest("/services/${args.serviceDestroy}", "DELETE", null, apiKey) + apiRequest("/services/${args.serviceDestroy}", "DELETE", null, publicKey, secretKey) println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") return } if (args.serviceExecute != null) { val payload = mutableMapOf("command" to args.serviceCommand!!) - val result = apiRequest("/services/${args.serviceExecute}/execute", "POST", payload, apiKey) + val result = apiRequest("/services/${args.serviceExecute}/execute", "POST", payload, publicKey, secretKey) if (result.containsKey("stdout")) { val stdout = result["stdout"] as? String if (stdout != null && stdout.isNotEmpty()) { @@ -326,7 +328,7 @@ fun cmdService(args: Args) { if (args.serviceDumpBootstrap != null) { System.err.println("Fetching bootstrap script from ${args.serviceDumpBootstrap}...") val payload = mutableMapOf("command" to "cat /tmp/bootstrap.sh") - val result = apiRequest("/services/${args.serviceDumpBootstrap}/execute", "POST", payload, apiKey) + val result = apiRequest("/services/${args.serviceDumpBootstrap}/execute", "POST", payload, publicKey, secretKey) val bootstrap = result["stdout"] as? String if (bootstrap != null && bootstrap.isNotEmpty()) { @@ -368,7 +370,7 @@ fun cmdService(args: Args) { payload["vcpu"] = args.vcpu } - val result = apiRequest("/services", "POST", payload, apiKey) + val result = apiRequest("/services", "POST", payload, publicKey, secretKey) println("${GREEN}Service created: ${result["id"] ?: "N/A"}${RESET}") println("Name: ${result["name"] ?: "N/A"}") if (result.containsKey("url")) { @@ -382,21 +384,21 @@ fun cmdService(args: Args) { } fun cmdKey(args: Args) { - val apiKey = getApiKey(args.apiKey) + val (publicKey, secretKey) = getApiKeys(args.apiKey) - val result = validateKey(apiKey) + val result = validateKey(publicKey, secretKey) val valid = result["valid"] as? Boolean ?: false val expired = result["expired"] as? Boolean ?: false - val publicKey = result["public_key"] as? String ?: "" + val pubKey = result["public_key"] as? String ?: "" val tier = result["tier"] as? String ?: "" val expiresAt = result["expires_at"] as? String ?: "" if (args.keyExtend) { - if (publicKey.isEmpty()) { + if (pubKey.isEmpty()) { System.err.println("${RED}Error: Could not retrieve public key${RESET}") exitProcess(1) } - val extendUrl = "$PORTAL_BASE/keys/extend?pk=$publicKey" + val extendUrl = "$PORTAL_BASE/keys/extend?pk=$pubKey" println("${YELLOW}Opening browser to extend key...${RESET}") println(extendUrl) @@ -418,13 +420,13 @@ fun cmdKey(args: Args) { if (expired) { println("${RED}Status: Expired${RESET}") - println("Public Key: $publicKey") + println("Public Key: $pubKey") println("Tier: $tier") println("Expired: $expiresAt") println("${YELLOW}To renew: Visit $PORTAL_BASE/keys/extend${RESET}") } else if (valid) { println("${GREEN}Status: Valid${RESET}") - println("Public Key: $publicKey") + println("Public Key: $pubKey") println("Tier: $tier") println("Expires: $expiresAt") } else { @@ -433,12 +435,21 @@ fun cmdKey(args: Args) { } } -fun validateKey(apiKey: String): Map { - val url = URL("$PORTAL_BASE/keys/validate") +fun validateKey(publicKey: String?, secretKey: String): Map { + val timestamp = System.currentTimeMillis() / 1000 + val method = "POST" + val path = "/keys/validate" + val body = "" + val signatureData = "$timestamp:$method:$path:$body" + val signature = hmacSha256(secretKey, signatureData) + + val url = URL("$PORTAL_BASE$path") val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "POST" - connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.requestMethod = method + connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}") + connection.setRequestProperty("X-Timestamp", timestamp.toString()) + connection.setRequestProperty("X-Signature", signature) connection.setRequestProperty("Content-Type", "application/json") connection.connectTimeout = 30000 connection.readTimeout = 30000 @@ -452,13 +463,39 @@ fun validateKey(apiKey: String): Map { return parseJson(response) } -fun getApiKey(argsKey: String?): String { - val key = argsKey ?: System.getenv("UNSANDBOX_API_KEY") - if (key.isNullOrEmpty()) { - System.err.println("${RED}Error: UNSANDBOX_API_KEY not set${RESET}") +fun getApiKeys(argsKey: String?): Pair { + var publicKey: String? = null + var secretKey: String? = null + + if (argsKey != null) { + secretKey = argsKey + publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + } else { + publicKey = System.getenv("UNSANDBOX_PUBLIC_KEY") + secretKey = System.getenv("UNSANDBOX_SECRET_KEY") + + if (publicKey == null || secretKey == null) { + val apiKey = System.getenv("UNSANDBOX_API_KEY") + if (apiKey != null && apiKey.isNotEmpty()) { + secretKey = apiKey + } + } + } + + if (secretKey.isNullOrEmpty()) { + System.err.println("${RED}Error: UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set${RESET}") exitProcess(1) } - return key + + return Pair(publicKey, secretKey) +} + +fun hmacSha256(secretKey: String, data: String): String { + val mac = Mac.getInstance("HmacSHA256") + val keySpec = SecretKeySpec(secretKey.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(keySpec) + val hash = mac.doFinal(data.toByteArray(Charsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } } fun detectLanguage(filename: String): String { @@ -469,20 +506,26 @@ fun detectLanguage(filename: String): String { return EXT_MAP[".$ext"] ?: throw RuntimeException("Unsupported file extension: .$ext") } -fun apiRequest(endpoint: String, method: String, data: Map?, apiKey: String): Map { +fun apiRequest(endpoint: String, method: String, data: Map?, publicKey: String?, secretKey: String): Map { + val timestamp = System.currentTimeMillis() / 1000 + val body = if (data != null) toJson(data) else "" + val signatureData = "$timestamp:$method:$endpoint:$body" + val signature = hmacSha256(secretKey, signatureData) + val url = URL(API_BASE + endpoint) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = method - connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Authorization", "Bearer ${publicKey ?: secretKey}") + connection.setRequestProperty("X-Timestamp", timestamp.toString()) + connection.setRequestProperty("X-Signature", signature) connection.setRequestProperty("Content-Type", "application/json") connection.connectTimeout = 30000 connection.readTimeout = 300000 if (data != null) { connection.doOutput = true - val json = toJson(data) - connection.outputStream.use { it.write(json.toByteArray()) } + connection.outputStream.use { it.write(body.toByteArray()) } } if (connection.responseCode !in 200..299) { diff --git a/un.lisp b/un.lisp index c84eba7..c84a354 100644 --- a/un.lisp +++ b/un.lisp @@ -104,38 +104,75 @@ (defun curl-post (api-key endpoint json-data) (let ((tmp-file (write-temp-file json-data))) (unwind-protect - (run-curl (list "curl" "-s" "-X" "POST" - (format nil "https://api.unsandbox.com~a" endpoint) - "-H" "Content-Type: application/json" - "-H" (format nil "Authorization: Bearer ~a" api-key) - "-d" (format nil "@~a" tmp-file))) + (destructuring-bind (public-key secret-key) (get-api-keys) + (let ((auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (base-args (list "curl" "-s" "-X" "POST" + (format nil "https://api.unsandbox.com~a" endpoint) + "-H" "Content-Type: application/json"))) + (run-curl (append base-args auth-headers (list "-d" (format nil "@~a" tmp-file)))))) (delete-file tmp-file)))) (defun curl-get (api-key endpoint) - (run-curl (list "curl" "-s" - (format nil "https://api.unsandbox.com~a" endpoint) - "-H" (format nil "Authorization: Bearer ~a" api-key)))) + (destructuring-bind (public-key secret-key) (get-api-keys) + (let ((auth-headers (build-auth-headers public-key secret-key "GET" endpoint "")) + (base-args (list "curl" "-s" + (format nil "https://api.unsandbox.com~a" endpoint)))) + (run-curl (append base-args auth-headers))))) (defun curl-delete (api-key endpoint) - (run-curl (list "curl" "-s" "-X" "DELETE" - (format nil "https://api.unsandbox.com~a" endpoint) - "-H" (format nil "Authorization: Bearer ~a" api-key)))) + (destructuring-bind (public-key secret-key) (get-api-keys) + (let ((auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "")) + (base-args (list "curl" "-s" "-X" "DELETE" + (format nil "https://api.unsandbox.com~a" endpoint)))) + (run-curl (append base-args auth-headers))))) (defun curl-post-portal (api-key endpoint json-data) (let ((tmp-file (write-temp-file json-data))) (unwind-protect - (run-curl (list "curl" "-s" "-X" "POST" - (format nil "~a~a" *portal-base* endpoint) - "-H" "Content-Type: application/json" - "-H" (format nil "Authorization: Bearer ~a" api-key) - "-d" (format nil "@~a" tmp-file))) + (destructuring-bind (public-key secret-key) (get-api-keys) + (let ((auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (base-args (list "curl" "-s" "-X" "POST" + (format nil "~a~a" *portal-base* endpoint) + "-H" "Content-Type: application/json"))) + (run-curl (append base-args auth-headers (list "-d" (format nil "@~a" tmp-file)))))) (delete-file tmp-file)))) +(defun get-api-keys () + (let ((public-key (uiop:getenv "UNSANDBOX_PUBLIC_KEY")) + (secret-key (uiop:getenv "UNSANDBOX_SECRET_KEY")) + (api-key (uiop:getenv "UNSANDBOX_API_KEY"))) + (cond + ((and public-key secret-key) (list public-key secret-key)) + (api-key (list api-key nil)) + (t (progn + (format t "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~%") + (uiop:quit 1)))))) + (defun get-api-key () - (or (uiop:getenv "UNSANDBOX_API_KEY") - (progn - (format t "Error: UNSANDBOX_API_KEY not set~%") - (uiop:quit 1)))) + (first (get-api-keys))) + +(defun hmac-sha256 (secret message) + "Compute HMAC-SHA256 using openssl command" + (let* ((secret-escaped (uiop:escape-sh-token secret)) + (message-escaped (uiop:escape-sh-token message)) + (cmd (format nil "echo -n ~a | openssl dgst -sha256 -hmac ~a | awk '{print $2}'" + message-escaped secret-escaped)) + (result (string-trim '(#\Space #\Tab #\Newline #\Return) + (uiop:run-program cmd :output :string)))) + result)) + +(defun make-signature (secret-key timestamp method path body) + (let ((message (format nil "~a:~a:~a:~a" timestamp method path body))) + (hmac-sha256 secret-key message))) + +(defun build-auth-headers (public-key secret-key method path body) + (if secret-key + (let* ((timestamp (write-to-string (floor (get-universal-time)))) + (signature (make-signature secret-key timestamp method path body))) + (list "-H" (format nil "Authorization: Bearer ~a" public-key) + "-H" (format nil "X-Timestamp: ~a" timestamp) + "-H" (format nil "X-Signature: ~a" signature))) + (list "-H" (format nil "Authorization: Bearer ~a" public-key)))) (defun execute-cmd (file) (let* ((api-key (get-api-key)) diff --git a/un.lua b/un.lua index 33f40ab..6292cb1 100644 --- a/un.lua +++ b/un.lua @@ -76,13 +76,23 @@ local EXT_MAP = { [".tcl"] = "tcl", [".raku"] = "raku", [".m"] = "objc" } -local function get_api_key(args_key) - local key = args_key or os.getenv("UNSANDBOX_API_KEY") - if not key then - io.stderr:write(RED .. "Error: UNSANDBOX_API_KEY not set" .. RESET .. "\n") - os.exit(1) +local function get_api_keys(args_key) + local public_key = os.getenv("UNSANDBOX_PUBLIC_KEY") + local secret_key = os.getenv("UNSANDBOX_SECRET_KEY") + + if not public_key or not secret_key then + local old_key = args_key or os.getenv("UNSANDBOX_API_KEY") + if old_key then + public_key = old_key + secret_key = old_key + else + io.stderr:write(RED .. "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" .. RESET .. "\n") + io.stderr:write(RED .. " (or legacy UNSANDBOX_API_KEY for backwards compatibility)" .. RESET .. "\n") + os.exit(1) + end end - return key + + return {public_key = public_key, secret_key = secret_key} end local function detect_language(filename) @@ -115,20 +125,46 @@ local function shell_escape(str) return "'" .. str:gsub("'", "'\\''") .. "'" end -local function api_request(endpoint, method, data, api_key) +local function api_request(endpoint, method, data, keys) method = method or "GET" local url = API_BASE .. endpoint local tmpfile = os.tmpname() + -- Generate timestamp and signature + local timestamp = tostring(os.time()) + local body = data and json.encode(data) or "" + + -- Parse URL to get path + local path = endpoint + + -- Create HMAC signature using openssl command + local message = timestamp .. ":" .. method .. ":" .. path .. ":" .. body + local sig_tmpfile = os.tmpname() + local msg_tmpfile = os.tmpname() + + -- Write message to temp file + local f = io.open(msg_tmpfile, "w") + f:write(message) + f:close() + + -- Generate HMAC using openssl + local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" + local sig_handle = io.popen(hmac_cmd) + local signature = sig_handle:read("*a"):gsub("%s+$", "") + sig_handle:close() + os.remove(msg_tmpfile) + local cmd = "curl -s -X " .. method .. " " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. api_key .. "'" .. + " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. + " -H 'X-Timestamp: " .. timestamp .. "'" .. + " -H 'X-Signature: " .. signature .. "'" .. " -H 'Content-Type: application/json'" + local data_file if data then - local payload = json.encode(data) - local data_file = os.tmpname() + data_file = os.tmpname() local f = io.open(data_file, "w") - f:write(payload) + f:write(body) f:close() cmd = cmd .. " -d @" .. shell_escape(data_file) end @@ -144,7 +180,7 @@ local function api_request(endpoint, method, data, api_key) file:close() os.remove(tmpfile) - if data then + if data_file then os.remove(data_file) end @@ -198,7 +234,7 @@ local function base64_decode(data) end local function cmd_execute(options) - local api_key = get_api_key(options.api_key) + local keys = get_api_keys(options.api_key) local code = read_file(options.source_file) local language = detect_language(options.source_file) @@ -233,7 +269,7 @@ local function cmd_execute(options) if options.network then payload.network = options.network end if options.vcpu then payload.vcpu = options.vcpu end - local result = api_request("/execute", "POST", payload, api_key) + local result = api_request("/execute", "POST", payload, keys) if result.stdout then io.write(BLUE .. result.stdout .. RESET) @@ -261,10 +297,10 @@ local function cmd_execute(options) end local function cmd_session(options) - local api_key = get_api_key(options.api_key) + local keys = get_api_keys(options.api_key) if options.list then - local result = api_request("/sessions", "GET", nil, api_key) + local result = api_request("/sessions", "GET", nil, keys) local sessions = result.sessions or {} if #sessions == 0 then print("No active sessions") @@ -280,7 +316,7 @@ local function cmd_session(options) end if options.kill then - api_request("/sessions/" .. options.kill, "DELETE", nil, api_key) + api_request("/sessions/" .. options.kill, "DELETE", nil, keys) print(GREEN .. "Session terminated: " .. options.kill .. RESET) return end @@ -299,21 +335,39 @@ local function cmd_session(options) if options.audit then payload.audit = true end print(YELLOW .. "Creating session..." .. RESET) - local result = api_request("/sessions", "POST", payload, api_key) + local result = api_request("/sessions", "POST", payload, keys) print(GREEN .. "Session created: " .. (result.id or "N/A") .. RESET) print(YELLOW .. "(Interactive sessions require WebSocket - use un2 for full support)" .. RESET) end local function cmd_key(options) - local api_key = get_api_key(options.api_key) + local keys = get_api_keys(options.api_key) if options.extend then -- Get public_key from validation response local url = PORTAL_BASE .. "/keys/validate" local tmpfile = os.tmpname() + local timestamp = tostring(os.time()) + local body = "" + local path = "/keys/validate" + local message = timestamp .. ":POST:" .. path .. ":" .. body + local msg_tmpfile = os.tmpname() + + local f = io.open(msg_tmpfile, "w") + f:write(message) + f:close() + + local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" + local sig_handle = io.popen(hmac_cmd) + local signature = sig_handle:read("*a"):gsub("%s+$", "") + sig_handle:close() + os.remove(msg_tmpfile) + local cmd = "curl -s -X POST " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. api_key .. "'" .. + " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. + " -H 'X-Timestamp: " .. timestamp .. "'" .. + " -H 'X-Signature: " .. signature .. "'" .. " -H 'Content-Type: application/json'" .. " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) @@ -351,8 +405,26 @@ local function cmd_key(options) local url = PORTAL_BASE .. "/keys/validate" local tmpfile = os.tmpname() + local timestamp = tostring(os.time()) + local body = "" + local path = "/keys/validate" + local message = timestamp .. ":POST:" .. path .. ":" .. body + local msg_tmpfile = os.tmpname() + + local f = io.open(msg_tmpfile, "w") + f:write(message) + f:close() + + local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" + local sig_handle = io.popen(hmac_cmd) + local signature = sig_handle:read("*a"):gsub("%s+$", "") + sig_handle:close() + os.remove(msg_tmpfile) + local cmd = "curl -s -X POST " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. api_key .. "'" .. + " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. + " -H 'X-Timestamp: " .. timestamp .. "'" .. + " -H 'X-Signature: " .. signature .. "'" .. " -H 'Content-Type: application/json'" .. " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) @@ -390,10 +462,10 @@ local function cmd_key(options) end local function cmd_service(options) - local api_key = get_api_key(options.api_key) + local keys = get_api_keys(options.api_key) if options.list then - local result = api_request("/services", "GET", nil, api_key) + local result = api_request("/services", "GET", nil, keys) local services = result.services or {} if #services == 0 then print("No services") @@ -411,44 +483,44 @@ local function cmd_service(options) end if options.info then - local result = api_request("/services/" .. options.info, "GET", nil, api_key) + local result = api_request("/services/" .. options.info, "GET", nil, keys) print(json.encode(result)) return end if options.logs then - local result = api_request("/services/" .. options.logs .. "/logs", "GET", nil, api_key) + local result = api_request("/services/" .. options.logs .. "/logs", "GET", nil, keys) print(result.logs or "") return end if options.tail then - local result = api_request("/services/" .. options.tail .. "/logs?lines=9000", "GET", nil, api_key) + local result = api_request("/services/" .. options.tail .. "/logs?lines=9000", "GET", nil, keys) print(result.logs or "") return end if options.sleep then - api_request("/services/" .. options.sleep .. "/sleep", "POST", nil, api_key) + api_request("/services/" .. options.sleep .. "/sleep", "POST", nil, keys) print(GREEN .. "Service sleeping: " .. options.sleep .. RESET) return end if options.wake then - api_request("/services/" .. options.wake .. "/wake", "POST", nil, api_key) + api_request("/services/" .. options.wake .. "/wake", "POST", nil, keys) print(GREEN .. "Service waking: " .. options.wake .. RESET) return end if options.destroy then - api_request("/services/" .. options.destroy, "DELETE", nil, api_key) + api_request("/services/" .. options.destroy, "DELETE", nil, keys) print(GREEN .. "Service destroyed: " .. options.destroy .. RESET) return end if options.execute then local payload = { command = options.command } - local result = api_request("/services/" .. options.execute .. "/execute", "POST", payload, api_key) + local result = api_request("/services/" .. options.execute .. "/execute", "POST", payload, keys) if result.stdout then io.write(BLUE .. result.stdout .. RESET) end if result.stderr then io.stderr:write(RED .. result.stderr .. RESET) end return @@ -457,7 +529,7 @@ local function cmd_service(options) if options.dump_bootstrap then io.stderr:write("Fetching bootstrap script from " .. options.dump_bootstrap .. "...\n") local payload = { command = "cat /tmp/bootstrap.sh" } - local result = api_request("/services/" .. options.dump_bootstrap .. "/execute", "POST", payload, api_key) + local result = api_request("/services/" .. options.dump_bootstrap .. "/execute", "POST", payload, keys) if result.stdout then local bootstrap = result.stdout @@ -514,7 +586,7 @@ local function cmd_service(options) if options.network then payload.network = options.network end if options.vcpu then payload.vcpu = options.vcpu end - local result = api_request("/services", "POST", payload, api_key) + local result = api_request("/services", "POST", payload, keys) print(GREEN .. "Service created: " .. (result.id or "N/A") .. RESET) print("Name: " .. (result.name or "N/A")) if result.url then print("URL: " .. result.url) end diff --git a/un.m b/un.m index 29ed7f3..d25b211 100644 --- a/un.m +++ b/un.m @@ -41,6 +41,7 @@ // Full-featured CLI matching un.c/un.py capabilities #import +#import static NSString* API_BASE = @"https://api.unsandbox.com"; static NSString* PORTAL_BASE = @"https://unsandbox.com"; @@ -69,13 +70,47 @@ NSDictionary* getExtMap() { }; } -NSString* getApiKey() { - NSString* key = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_API_KEY"]; - if (!key) { - fprintf(stderr, "%sError: UNSANDBOX_API_KEY not set%s\n", [RED UTF8String], [RESET UTF8String]); +void getApiKeys(NSString** publicKey, NSString** secretKey) { + // Try new-style keys first + *publicKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_PUBLIC_KEY"]; + *secretKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_SECRET_KEY"]; + + // Fall back to old-style single key + if (!*publicKey || [*publicKey length] == 0) { + NSString* oldKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_API_KEY"]; + if (!oldKey) { + fprintf(stderr, "%sError: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set%s\n", + [RED UTF8String], [RESET UTF8String]); + exit(1); + } + *publicKey = oldKey; + *secretKey = oldKey; + return; + } + + if (!*secretKey || [*secretKey length] == 0) { + fprintf(stderr, "%sError: UNSANDBOX_SECRET_KEY not set%s\n", [RED UTF8String], [RESET UTF8String]); exit(1); } - return key; +} + +NSString* hmacSha256Hex(NSString* key, NSString* message) { + const char* cKey = [key UTF8String]; + const char* cMessage = [message UTF8String]; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + + CCHmac(kCCHmacAlgSHA256, cKey, strlen(cKey), cMessage, strlen(cMessage), digest); + + NSMutableString* hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) { + [hex appendFormat:@"%02x", digest[i]]; + } + return hex; +} + +NSString* computeSignature(NSString* secretKey, long timestamp, NSString* method, NSString* path, NSString* body) { + NSString* message = [NSString stringWithFormat:@"%ld:%@:%@:%@", timestamp, method, path, body]; + return hmacSha256Hex(secretKey, message); } NSString* detectLanguage(NSString* filename) { @@ -92,15 +127,15 @@ NSString* detectLanguage(NSString* filename) { return language; } -NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* data, NSString* apiKey) { +NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* data, NSString* publicKey, NSString* secretKey) { NSString* urlString = [API_BASE stringByAppendingString:endpoint]; NSURL* url = [NSURL URLWithString:urlString]; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; [request setHTTPMethod:method]; - [request setValue:[@"Bearer " stringByAppendingString:apiKey] forHTTPHeaderField:@"Authorization"]; - [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; [request setTimeoutInterval:300]; + // Prepare body + NSString* bodyString = @""; if (data) { NSError* error = nil; NSData* jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; @@ -109,9 +144,20 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat [RED UTF8String], [[error localizedDescription] UTF8String], [RESET UTF8String]); exit(1); } + bodyString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; [request setHTTPBody:jsonData]; } + // Generate timestamp and signature + long timestamp = (long)[[NSDate date] timeIntervalSince1970]; + NSString* signature = computeSignature(secretKey, timestamp, method, endpoint, bodyString); + + // Set headers + [request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + NSHTTPURLResponse* response = nil; NSError* error = nil; NSData* responseData = [NSURLConnection sendSynchronousRequest:request @@ -139,7 +185,8 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat } void cmdExecute(NSArray* args) { - NSString* apiKey = getApiKey(); + NSString* publicKey, *secretKey; + getApiKeys(&publicKey, &secretKey); NSString* sourceFile = nil; NSMutableDictionary* envVars = [NSMutableDictionary dictionary]; NSMutableArray* inputFiles = [NSMutableArray array]; @@ -233,7 +280,7 @@ void cmdExecute(NSArray* args) { } // Execute - NSDictionary* result = apiRequest(@"/execute", @"POST", payload, apiKey); + NSDictionary* result = apiRequest(@"/execute", @"POST", payload, publicKey, secretKey); // Print output NSString* stdoutText = result[@"stdout"] ?: @""; @@ -361,7 +408,8 @@ void validateKey(NSString* apiKey, BOOL shouldExtend) { } void cmdKey(NSArray* args) { - NSString* apiKey = getApiKey(); + NSString* publicKey, *secretKey; + getApiKeys(&publicKey, &secretKey); BOOL shouldExtend = NO; for (NSString* arg in args) { @@ -370,11 +418,13 @@ void cmdKey(NSArray* args) { } } - validateKey(apiKey, shouldExtend); + // For portal validation, we use public key as bearer token + validateKey(publicKey, shouldExtend); } void cmdSession(NSArray* args) { - NSString* apiKey = getApiKey(); + NSString* publicKey, *secretKey; + getApiKeys(&publicKey, &secretKey); BOOL listMode = NO; NSString* killId = nil; NSString* shell = nil; @@ -398,7 +448,7 @@ void cmdSession(NSArray* args) { } if (listMode) { - NSDictionary* result = apiRequest(@"/sessions", @"GET", nil, apiKey); + NSDictionary* result = apiRequest(@"/sessions", @"GET", nil, publicKey, secretKey); NSArray* sessions = result[@"sessions"]; if ([sessions count] == 0) { printf("No active sessions\n"); @@ -417,7 +467,7 @@ void cmdSession(NSArray* args) { if (killId) { NSString* endpoint = [NSString stringWithFormat:@"/sessions/%@", killId]; - apiRequest(endpoint, @"DELETE", nil, apiKey); + apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); printf("%sSession terminated: %s%s\n", [GREEN UTF8String], [killId UTF8String], [RESET UTF8String]); return; } @@ -430,14 +480,15 @@ void cmdSession(NSArray* args) { if (vcpu > 0) payload[@"vcpu"] = @(vcpu); printf("%sCreating session...%s\n", [YELLOW UTF8String], [RESET UTF8String]); - NSDictionary* result = apiRequest(@"/sessions", @"POST", payload, apiKey); + NSDictionary* result = apiRequest(@"/sessions", @"POST", payload, publicKey, secretKey); printf("%sSession created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); printf("%s(Interactive sessions require WebSocket - use un2 for full support)%s\n", [YELLOW UTF8String], [RESET UTF8String]); } void cmdService(NSArray* args) { - NSString* apiKey = getApiKey(); + NSString* publicKey, *secretKey; + getApiKeys(&publicKey, &secretKey); BOOL listMode = NO; NSString* infoId = nil; NSString* logsId = nil; @@ -488,7 +539,7 @@ void cmdService(NSArray* args) { } if (listMode) { - NSDictionary* result = apiRequest(@"/services", @"GET", nil, apiKey); + NSDictionary* result = apiRequest(@"/services", @"GET", nil, publicKey, secretKey); NSArray* services = result[@"services"]; if ([services count] == 0) { printf("No services\n"); @@ -512,7 +563,7 @@ void cmdService(NSArray* args) { if (infoId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@", infoId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, apiKey); + NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; printf("%s\n", [jsonString UTF8String]); @@ -521,28 +572,28 @@ void cmdService(NSArray* args) { if (logsId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/logs", logsId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, apiKey); + NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); printf("%s", [result[@"logs"] UTF8String]); return; } if (sleepId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/sleep", sleepId]; - apiRequest(endpoint, @"POST", nil, apiKey); + apiRequest(endpoint, @"POST", nil, publicKey, secretKey); printf("%sService sleeping: %s%s\n", [GREEN UTF8String], [sleepId UTF8String], [RESET UTF8String]); return; } if (wakeId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/wake", wakeId]; - apiRequest(endpoint, @"POST", nil, apiKey); + apiRequest(endpoint, @"POST", nil, publicKey, secretKey); printf("%sService waking: %s%s\n", [GREEN UTF8String], [wakeId UTF8String], [RESET UTF8String]); return; } if (destroyId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@", destroyId]; - apiRequest(endpoint, @"DELETE", nil, apiKey); + apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); printf("%sService destroyed: %s%s\n", [GREEN UTF8String], [destroyId UTF8String], [RESET UTF8String]); return; } @@ -551,7 +602,7 @@ void cmdService(NSArray* args) { fprintf(stderr, "Fetching bootstrap script from %s...\n", [dumpBootstrapId UTF8String]); NSDictionary* payload = @{@"command": @"cat /tmp/bootstrap.sh"}; NSString* endpoint = [NSString stringWithFormat:@"/services/%@/execute", dumpBootstrapId]; - NSDictionary* result = apiRequest(endpoint, @"POST", payload, apiKey); + NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey); if (result[@"stdout"] && [result[@"stdout"] length] > 0) { NSString* bootstrap = result[@"stdout"]; @@ -610,7 +661,7 @@ void cmdService(NSArray* args) { if (network) payload[@"network"] = network; if (vcpu > 0) payload[@"vcpu"] = @(vcpu); - NSDictionary* result = apiRequest(@"/services", @"POST", payload, apiKey); + NSDictionary* result = apiRequest(@"/services", @"POST", payload, publicKey, secretKey); printf("%sService created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); printf("Name: %s\n", [result[@"name"] UTF8String]); if (result[@"url"]) { diff --git a/un.ml b/un.ml index b0b8d8f..dbd04e2 100755 --- a/un.ml +++ b/un.ml @@ -114,11 +114,22 @@ let escape_json s = (* Execute curl command *) let curl_post api_key endpoint json = - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '%s'" - endpoint api_key json in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc json; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s" + endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in - let output = read_file "/dev/stdin" in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in let _ = Unix.close_process_in ic in + Sys.remove tmp_file; output let portal_curl_post api_key endpoint json = @@ -126,8 +137,10 @@ let portal_curl_post api_key endpoint json = let oc = open_out tmp_file in output_string oc json; close_out oc; - let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - portal_base endpoint api_key tmp_file in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + portal_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in let rec read_all acc = try let line = input_line ic in read_all (acc ^ line ^ "\n") @@ -139,8 +152,10 @@ let portal_curl_post api_key endpoint json = output let curl_get api_key endpoint = - let cmd = Printf.sprintf "curl -s https://api.unsandbox.com%s -H 'Authorization: Bearer %s'" - endpoint api_key in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "GET" endpoint "" in + let cmd = Printf.sprintf "curl -s https://api.unsandbox.com%s%s" + endpoint auth_headers in let ic = Unix.open_process_in cmd in let rec read_all acc = try @@ -153,17 +168,18 @@ let curl_get api_key endpoint = output let curl_delete api_key endpoint = - let cmd = Printf.sprintf "curl -s -X DELETE https://api.unsandbox.com%s -H 'Authorization: Bearer %s'" - endpoint api_key in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "DELETE" endpoint "" in + let cmd = Printf.sprintf "curl -s -X DELETE https://api.unsandbox.com%s%s" + endpoint auth_headers in let ic = Unix.open_process_in cmd in - let output = read_all "" where - let rec read_all acc = - try - let line = input_line ic in - read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in read_all "" + let rec read_all acc = + try + let line = input_line ic in + read_all (acc ^ line ^ "\n") + with End_of_file -> acc in + let output = read_all "" in let _ = Unix.close_process_in ic in output @@ -214,13 +230,46 @@ let unescape_json s = let s = Str.global_replace (Str.regexp "\\\\\\\\") "\\" s in s -(* Get API key *) -let get_api_key () = - try Sys.getenv "UNSANDBOX_API_KEY" - with Not_found -> - Printf.fprintf stderr "Error: UNSANDBOX_API_KEY not set\n"; +(* Get API keys *) +let get_api_keys () = + let public_key = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in + let secret_key = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in + let api_key = try Some (Sys.getenv "UNSANDBOX_API_KEY") with Not_found -> None in + match (public_key, secret_key, api_key) with + | (Some pk, Some sk, _) -> (pk, Some sk) + | (_, _, Some ak) -> (ak, None) + | _ -> + Printf.fprintf stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)\n"; exit 1 +let get_api_key () = + let (public_key, _) = get_api_keys () in + public_key + +(* HMAC-SHA256 using openssl command *) +let hmac_sha256 secret message = + let cmd = Printf.sprintf "echo -n '%s' | openssl dgst -sha256 -hmac '%s' | awk '{print $2}'" + (Str.global_replace (Str.regexp "'") "'\\''" message) + (Str.global_replace (Str.regexp "'") "'\\''" secret) in + let ic = Unix.open_process_in cmd in + let result = input_line ic in + let _ = Unix.close_process_in ic in + String.trim result + +let make_signature secret_key timestamp method_ path body = + let message = Printf.sprintf "%s:%s:%s:%s" timestamp method_ path body in + hmac_sha256 secret_key message + +let build_auth_headers public_key secret_key method_ path body = + match secret_key with + | Some sk -> + let timestamp = string_of_int (int_of_float (Unix.time ())) in + let signature = make_signature sk timestamp method_ path body in + Printf.sprintf " -H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'" + public_key timestamp signature + | None -> + Printf.sprintf " -H 'Authorization: Bearer %s'" public_key + (* Execute command *) let execute_command file env_vars artifacts out_dir network vcpu = let api_key = get_api_key () in diff --git a/un.nim b/un.nim index 29a85d4..97fe60b 100644 --- a/un.nim +++ b/un.nim @@ -43,7 +43,7 @@ # un.nim session --list # un.nim service --name web --ports 8080 -import os, strutils, osproc, strformat +import os, strutils, osproc, strformat, times const API_BASE = "https://api.unsandbox.com" @@ -76,10 +76,29 @@ proc escapeJson(s: string): string = of '\t': result.add("\\t") else: result.add(c) +proc computeHmac(secretKey: string, message: string): string = + let cmd = fmt"echo -n '{message}' | openssl dgst -sha256 -hmac '{secretKey}' -hex 2>/dev/null | sed 's/.*= //'" + result = execProcess(cmd).strip() + +proc getTimestamp(): string = + result = $toUnix(getTime()) + +proc buildAuthHeaders(meth: string, path: string, body: string, publicKey: string, secretKey: string): string = + if secretKey == "": + # Legacy mode: use public_key as bearer token + return fmt"-H 'Authorization: Bearer {publicKey}'" + + # HMAC mode + let timestamp = getTimestamp() + let message = fmt"{timestamp}:{meth}:{path}:{body}" + let signature = computeHmac(secretKey, message) + + return fmt"-H 'Authorization: Bearer {publicKey}' -H 'X-Timestamp: {timestamp}' -H 'X-Signature: {signature}'" + proc execCurl(cmd: string): string = result = execProcess(cmd) -proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, apiKey: string) = +proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: string, vcpu: int, publicKey: string, secretKey: string) = let lang = detectLanguage(sourceFile) if lang == "": stderr.writeLine(RED & "Error: Cannot detect language" & RESET) @@ -102,17 +121,21 @@ proc cmdExecute(sourceFile: string, envs: seq[string], artifacts: bool, network: if vcpu > 0: json.add(fmt""","vcpu":{vcpu}""") json.add("}") - let cmd = fmt"""curl -s -X POST '{API_BASE}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {apiKey}' -d '{json}'""" + let authHeaders = buildAuthHeaders("POST", "/execute", json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, screen: bool, apiKey: string) = +proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, screen: bool, publicKey: string, secretKey: string) = if list: - let cmd = fmt"""curl -s -X GET '{API_BASE}/sessions' -H 'Authorization: Bearer {apiKey}'""" + let authHeaders = buildAuthHeaders("GET", "/sessions", "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/sessions' {authHeaders}""" echo execCurl(cmd) return if kill != "": - let cmd = fmt"""curl -s -X DELETE '{API_BASE}/sessions/{kill}' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/sessions/{kill}" + let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X DELETE '{API_BASE}/sessions/{kill}' {authHeaders}""" discard execCurl(cmd) echo GREEN & "Session terminated: " & kill & RESET return @@ -125,51 +148,67 @@ proc cmdSession(list: bool, kill, shell, network: string, vcpu: int, tmux, scree json.add("}") echo YELLOW & "Creating session..." & RESET - let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' -H 'Authorization: Bearer {apiKey}' -d '{json}'""" + let authHeaders = buildAuthHeaders("POST", "/sessions", json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/sessions' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) -proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, apiKey: string) = +proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, logs, tail, sleep, wake, destroy, execute, command, dumpBootstrap, dumpFile, network: string, vcpu: int, publicKey: string, secretKey: string) = if list: - let cmd = fmt"""curl -s -X GET '{API_BASE}/services' -H 'Authorization: Bearer {apiKey}'""" + let authHeaders = buildAuthHeaders("GET", "/services", "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/services' {authHeaders}""" echo execCurl(cmd) return if info != "": - let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{info}' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{info}" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{info}' {authHeaders}""" echo execCurl(cmd) return if logs != "": - let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{logs}/logs' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{logs}/logs" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{logs}/logs' {authHeaders}""" stdout.write(execCurl(cmd)) return if tail != "": - let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{tail}/logs?lines=9000' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{tail}/logs" + let authHeaders = buildAuthHeaders("GET", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X GET '{API_BASE}/services/{tail}/logs?lines=9000' {authHeaders}""" stdout.write(execCurl(cmd)) return if sleep != "": - let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{sleep}/sleep' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{sleep}/sleep" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{sleep}/sleep' {authHeaders}""" discard execCurl(cmd) echo GREEN & "Service sleeping: " & sleep & RESET return if wake != "": - let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{wake}/wake' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{wake}/wake" + let authHeaders = buildAuthHeaders("POST", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{wake}/wake' {authHeaders}""" discard execCurl(cmd) echo GREEN & "Service waking: " & wake & RESET return if destroy != "": - let cmd = fmt"""curl -s -X DELETE '{API_BASE}/services/{destroy}' -H 'Authorization: Bearer {apiKey}'""" + let path = fmt"/services/{destroy}" + let authHeaders = buildAuthHeaders("DELETE", path, "", publicKey, secretKey) + let cmd = fmt"""curl -s -X DELETE '{API_BASE}/services/{destroy}' {authHeaders}""" discard execCurl(cmd) echo GREEN & "Service destroyed: " & destroy & RESET return if execute != "": let json = fmt"""{"command":"{escapeJson(command)}"}""" - let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{execute}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {apiKey}' -d '{json}'""" + let path = fmt"/services/{execute}/execute" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{execute}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" let result = execCurl(cmd) # Simple parsing for stdout/stderr @@ -202,7 +241,10 @@ proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, l if dumpBootstrap != "": stderr.writeLine("Fetching bootstrap script from " & dumpBootstrap & "...") - let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{dumpBootstrap}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {apiKey}' -d '{{"command":"cat /tmp/bootstrap.sh"}}'""" + let json = """{"command":"cat /tmp/bootstrap.sh"}""" + let path = fmt"/services/{dumpBootstrap}/execute" + let authHeaders = buildAuthHeaders("POST", path, json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services/{dumpBootstrap}/execute' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" let result = execCurl(cmd) let stdoutStart = result.find("\"stdout\":\"") @@ -252,15 +294,17 @@ proc cmdService(name, ports, bootstrap, serviceType: string, list: bool, info, l json.add("}") echo YELLOW & "Creating service..." & RESET - let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer {apiKey}' -d '{json}'""" + let authHeaders = buildAuthHeaders("POST", "/services", json, publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{API_BASE}/services' -H 'Content-Type: application/json' {authHeaders} -d '{json}'""" echo execCurl(cmd) return stderr.writeLine(RED & "Error: Specify --name to create a service" & RESET) quit(1) -proc cmdKey(extend: bool, apiKey: string) = - let cmd = fmt"""curl -s -X POST '{PORTAL_BASE}/keys/validate' -H 'Authorization: Bearer {apiKey}'""" +proc cmdKey(extend: bool, publicKey: string, secretKey: string) = + let authHeaders = buildAuthHeaders("POST", "/keys/validate", "", publicKey, secretKey) + let cmd = fmt"""curl -s -X POST '{PORTAL_BASE}/keys/validate' {authHeaders}""" let response = execCurl(cmd) # Parse JSON response manually (simple approach) @@ -271,10 +315,10 @@ proc cmdKey(extend: bool, apiKey: string) = let pkStart = response.find("\"public_key\":\"") + 14 let pkEnd = response.find("\"", pkStart) if pkEnd > pkStart: - let publicKey = response[pkStart.. pkStart: - publicKey = response[pkStart.. 'tcl', '.raku' => 'raku', '.m' => 'objc' ]; -function get_api_key($args_key = null) { - $key = $args_key ?: getenv('UNSANDBOX_API_KEY'); - if (!$key) { - fwrite(STDERR, RED . "Error: UNSANDBOX_API_KEY not set" . RESET . "\n"); - exit(1); +function get_api_keys($args_key = null) { + $public_key = getenv('UNSANDBOX_PUBLIC_KEY'); + $secret_key = getenv('UNSANDBOX_SECRET_KEY'); + + if (!$public_key || !$secret_key) { + $old_key = $args_key ?: getenv('UNSANDBOX_API_KEY'); + if ($old_key) { + $public_key = $old_key; + $secret_key = $old_key; + } else { + fwrite(STDERR, RED . "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" . RESET . "\n"); + fwrite(STDERR, RED . " (or legacy UNSANDBOX_API_KEY for backwards compatibility)" . RESET . "\n"); + exit(1); + } } - return $key; + + return ['public_key' => $public_key, 'secret_key' => $secret_key]; } function detect_language($filename) { @@ -109,12 +119,23 @@ function detect_language($filename) { return $lang; } -function api_request($endpoint, $method = 'GET', $data = null, $api_key = null) { +function api_request($endpoint, $method = 'GET', $data = null, $keys = null) { $url = API_BASE . $endpoint; $ch = curl_init($url); + $timestamp = (string)time(); + $body = $data ? json_encode($data) : ''; + + // Parse URL to get path and query + $parsed_url = parse_url($url); + $path = $parsed_url['path'] . (isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''); + $message = "$timestamp:$method:$path:$body"; + $signature = hash_hmac('sha256', $message, $keys['secret_key']); + $headers = [ - 'Authorization: Bearer ' . $api_key, + 'Authorization: Bearer ' . $keys['public_key'], + 'X-Timestamp: ' . $timestamp, + 'X-Signature: ' . $signature, 'Content-Type: application/json' ]; @@ -126,7 +147,7 @@ function api_request($endpoint, $method = 'GET', $data = null, $api_key = null) ]); if ($data) { - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $response = curl_exec($ch); @@ -149,7 +170,7 @@ function api_request($endpoint, $method = 'GET', $data = null, $api_key = null) } function cmd_execute($options) { - $api_key = get_api_key($options['api_key']); + $keys = get_api_keys($options['api_key']); if (!file_exists($options['source_file'])) { fwrite(STDERR, RED . "Error: File not found: {$options['source_file']}" . RESET . "\n"); @@ -193,7 +214,7 @@ function cmd_execute($options) { if ($options['network']) $payload['network'] = $options['network']; if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; - $result = api_request('/execute', 'POST', $payload, $api_key); + $result = api_request('/execute', 'POST', $payload, $keys); if (!empty($result['stdout'])) { echo BLUE . $result['stdout'] . RESET; @@ -221,10 +242,10 @@ function cmd_execute($options) { } function cmd_session($options) { - $api_key = get_api_key($options['api_key']); + $keys = get_api_keys($options['api_key']); if ($options['list']) { - $result = api_request('/sessions', 'GET', null, $api_key); + $result = api_request('/sessions', 'GET', null, $keys); $sessions = $result['sessions'] ?? []; if (empty($sessions)) { echo "No active sessions\n"; @@ -240,7 +261,7 @@ function cmd_session($options) { } if ($options['kill']) { - api_request("/sessions/{$options['kill']}", 'DELETE', null, $api_key); + api_request("/sessions/{$options['kill']}", 'DELETE', null, $keys); echo GREEN . "Session terminated: {$options['kill']}" . RESET . "\n"; return; } @@ -259,17 +280,27 @@ function cmd_session($options) { if ($options['audit']) $payload['audit'] = true; echo YELLOW . "Creating session..." . RESET . "\n"; - $result = api_request('/sessions', 'POST', $payload, $api_key); + $result = api_request('/sessions', 'POST', $payload, $keys); echo GREEN . "Session created: " . ($result['id'] ?? 'N/A') . RESET . "\n"; echo YELLOW . "(Interactive sessions require WebSocket - use un2 for full support)" . RESET . "\n"; } -function validate_key($api_key) { +function validate_key($keys) { $url = PORTAL_BASE . '/keys/validate'; $ch = curl_init($url); + $timestamp = (string)time(); + $body = ''; + + $parsed_url = parse_url($url); + $path = $parsed_url['path']; + $message = "$timestamp:POST:$path:$body"; + $signature = hash_hmac('sha256', $message, $keys['secret_key']); + $headers = [ - 'Authorization: Bearer ' . $api_key, + 'Authorization: Bearer ' . $keys['public_key'], + 'X-Timestamp: ' . $timestamp, + 'X-Signature: ' . $signature, 'Content-Type: application/json' ]; @@ -322,15 +353,25 @@ function validate_key($api_key) { } function cmd_key($options) { - $api_key = get_api_key($options['api_key']); + $keys = get_api_keys($options['api_key']); if ($options['extend']) { // First validate to get public_key $url = PORTAL_BASE . '/keys/validate'; $ch = curl_init($url); + $timestamp = (string)time(); + $body = ''; + + $parsed_url = parse_url($url); + $path = $parsed_url['path']; + $message = "$timestamp:POST:$path:$body"; + $signature = hash_hmac('sha256', $message, $keys['secret_key']); + $headers = [ - 'Authorization: Bearer ' . $api_key, + 'Authorization: Bearer ' . $keys['public_key'], + 'X-Timestamp: ' . $timestamp, + 'X-Signature: ' . $signature, 'Content-Type: application/json' ]; @@ -367,15 +408,15 @@ function cmd_key($options) { echo "$extend_url\n"; } } else { - validate_key($api_key); + validate_key($keys); } } function cmd_service($options) { - $api_key = get_api_key($options['api_key']); + $keys = get_api_keys($options['api_key']); if ($options['list']) { - $result = api_request('/services', 'GET', null, $api_key); + $result = api_request('/services', 'GET', null, $keys); $services = $result['services'] ?? []; if (empty($services)) { echo "No services\n"; @@ -393,44 +434,44 @@ function cmd_service($options) { } if ($options['info']) { - $result = api_request("/services/{$options['info']}", 'GET', null, $api_key); + $result = api_request("/services/{$options['info']}", 'GET', null, $keys); echo json_encode($result, JSON_PRETTY_PRINT) . "\n"; return; } if ($options['logs']) { - $result = api_request("/services/{$options['logs']}/logs", 'GET', null, $api_key); + $result = api_request("/services/{$options['logs']}/logs", 'GET', null, $keys); echo $result['logs'] ?? ''; return; } if ($options['tail']) { - $result = api_request("/services/{$options['tail']}/logs?lines=9000", 'GET', null, $api_key); + $result = api_request("/services/{$options['tail']}/logs?lines=9000", 'GET', null, $keys); echo $result['logs'] ?? ''; return; } if ($options['sleep']) { - api_request("/services/{$options['sleep']}/sleep", 'POST', null, $api_key); + api_request("/services/{$options['sleep']}/sleep", 'POST', null, $keys); echo GREEN . "Service sleeping: {$options['sleep']}" . RESET . "\n"; return; } if ($options['wake']) { - api_request("/services/{$options['wake']}/wake", 'POST', null, $api_key); + api_request("/services/{$options['wake']}/wake", 'POST', null, $keys); echo GREEN . "Service waking: {$options['wake']}" . RESET . "\n"; return; } if ($options['destroy']) { - api_request("/services/{$options['destroy']}", 'DELETE', null, $api_key); + api_request("/services/{$options['destroy']}", 'DELETE', null, $keys); echo GREEN . "Service destroyed: {$options['destroy']}" . RESET . "\n"; return; } if ($options['execute']) { $payload = ['command' => $options['command']]; - $result = api_request("/services/{$options['execute']}/execute", 'POST', $payload, $api_key); + $result = api_request("/services/{$options['execute']}/execute", 'POST', $payload, $keys); if (!empty($result['stdout'])) echo BLUE . $result['stdout'] . RESET; if (!empty($result['stderr'])) fwrite(STDERR, RED . $result['stderr'] . RESET); return; @@ -439,7 +480,7 @@ function cmd_service($options) { if ($options['dump_bootstrap']) { fwrite(STDERR, "Fetching bootstrap script from {$options['dump_bootstrap']}...\n"); $payload = ['command' => 'cat /tmp/bootstrap.sh']; - $result = api_request("/services/{$options['dump_bootstrap']}/execute", 'POST', $payload, $api_key); + $result = api_request("/services/{$options['dump_bootstrap']}/execute", 'POST', $payload, $keys); if (!empty($result['stdout'])) { $bootstrap = $result['stdout']; @@ -483,7 +524,7 @@ function cmd_service($options) { if ($options['network']) $payload['network'] = $options['network']; if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; - $result = api_request('/services', 'POST', $payload, $api_key); + $result = api_request('/services', 'POST', $payload, $keys); echo GREEN . "Service created: " . ($result['id'] ?? 'N/A') . RESET . "\n"; echo "Name: " . ($result['name'] ?? 'N/A') . "\n"; if (!empty($result['url'])) echo "URL: {$result['url']}\n"; diff --git a/un.pl b/un.pl index bbe726c..29313a0 100644 --- a/un.pl +++ b/un.pl @@ -57,6 +57,7 @@ use LWP::UserAgent; use HTTP::Request; use MIME::Base64; use File::Path qw(make_path); +use Digest::SHA qw(hmac_sha256_hex); my $API_BASE = 'https://api.unsandbox.com'; my $PORTAL_BASE = 'https://unsandbox.com'; @@ -84,12 +85,20 @@ my %EXT_MAP = ( sub get_api_key { my ($args_key) = @_; - my $key = $args_key || $ENV{'UNSANDBOX_API_KEY'}; - unless ($key) { - print STDERR "${RED}Error: UNSANDBOX_API_KEY not set${RESET}\n"; + my $public_key = $ENV{'UNSANDBOX_PUBLIC_KEY'} || ''; + my $secret_key = $ENV{'UNSANDBOX_SECRET_KEY'} || ''; + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if (!$public_key && $ENV{'UNSANDBOX_API_KEY'}) { + $public_key = $ENV{'UNSANDBOX_API_KEY'}; + $secret_key = ''; + } + + unless ($public_key) { + print STDERR "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}\n"; exit 1; } - return $key; + return ($public_key, $secret_key); } sub detect_language { @@ -117,17 +126,28 @@ sub detect_language { } sub api_request { - my ($endpoint, $method, $data, $api_key) = @_; + my ($endpoint, $method, $data, $public_key, $secret_key) = @_; $method //= 'GET'; my $url = "$API_BASE$endpoint"; my $ua = LWP::UserAgent->new(timeout => 300); my $request = HTTP::Request->new($method => $url); - $request->header('Authorization' => "Bearer $api_key"); + $request->header('Authorization' => "Bearer $public_key"); $request->header('Content-Type' => 'application/json'); + my $body = ''; if ($data) { - $request->content(encode_json($data)); + $body = encode_json($data); + $request->content($body); + } + + # Add HMAC signature if secret_key is present + if ($secret_key) { + my $timestamp = time(); + my $sig_input = "${timestamp}:${method}:${endpoint}:${body}"; + my $signature = hmac_sha256_hex($sig_input, $secret_key); + $request->header('X-Timestamp' => $timestamp); + $request->header('X-Signature' => $signature); } my $response = $ua->request($request); @@ -142,7 +162,7 @@ sub api_request { sub cmd_execute { my ($options) = @_; - my $api_key = get_api_key($options->{api_key}); + my ($public_key, $secret_key) = get_api_key($options->{api_key}); unless (-e $options->{source_file}) { print STDERR "${RED}Error: File not found: $options->{source_file}${RESET}\n"; @@ -190,7 +210,7 @@ sub cmd_execute { $payload->{network} = $options->{network} if $options->{network}; $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; - my $result = api_request('/execute', 'POST', $payload, $api_key); + my $result = api_request('/execute', 'POST', $payload, $public_key, $secret_key); print "${BLUE}$result->{stdout}${RESET}" if $result->{stdout}; print STDERR "${RED}$result->{stderr}${RESET}" if $result->{stderr}; @@ -215,10 +235,10 @@ sub cmd_execute { sub cmd_session { my ($options) = @_; - my $api_key = get_api_key($options->{api_key}); + my ($public_key, $secret_key) = get_api_key($options->{api_key}); if ($options->{list}) { - my $result = api_request('/sessions', 'GET', undef, $api_key); + my $result = api_request('/sessions', 'GET', undef, $public_key, $secret_key); my $sessions = $result->{sessions} || []; if (@$sessions == 0) { print "No active sessions\n"; @@ -234,7 +254,7 @@ sub cmd_session { } if ($options->{kill}) { - api_request("/sessions/$options->{kill}", 'DELETE', undef, $api_key); + api_request("/sessions/$options->{kill}", 'DELETE', undef, $public_key, $secret_key); print "${GREEN}Session terminated: $options->{kill}${RESET}\n"; return; } @@ -253,17 +273,17 @@ sub cmd_session { $payload->{audit} = JSON::PP::true if $options->{audit}; print "${YELLOW}Creating session...${RESET}\n"; - my $result = api_request('/sessions', 'POST', $payload, $api_key); + my $result = api_request('/sessions', 'POST', $payload, $public_key, $secret_key); print "${GREEN}Session created: ", ($result->{id} // 'N/A'), "${RESET}\n"; print "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}\n"; } sub cmd_service { my ($options) = @_; - my $api_key = get_api_key($options->{api_key}); + my ($public_key, $secret_key) = get_api_key($options->{api_key}); if ($options->{list}) { - my $result = api_request('/services', 'GET', undef, $api_key); + my $result = api_request('/services', 'GET', undef, $public_key, $secret_key); my $services = $result->{services} || []; if (@$services == 0) { print "No services\n"; @@ -281,45 +301,45 @@ sub cmd_service { } if ($options->{info}) { - my $result = api_request("/services/$options->{info}", 'GET', undef, $api_key); + my $result = api_request("/services/$options->{info}", 'GET', undef, $public_key, $secret_key); print encode_json($result); print "\n"; return; } if ($options->{logs}) { - my $result = api_request("/services/$options->{logs}/logs", 'GET', undef, $api_key); + my $result = api_request("/services/$options->{logs}/logs", 'GET', undef, $public_key, $secret_key); print $result->{logs} // ''; return; } if ($options->{tail}) { - my $result = api_request("/services/$options->{tail}/logs?lines=9000", 'GET', undef, $api_key); + my $result = api_request("/services/$options->{tail}/logs?lines=9000", 'GET', undef, $public_key, $secret_key); print $result->{logs} // ''; return; } if ($options->{sleep}) { - api_request("/services/$options->{sleep}/sleep", 'POST', undef, $api_key); + api_request("/services/$options->{sleep}/sleep", 'POST', undef, $public_key, $secret_key); print "${GREEN}Service sleeping: $options->{sleep}${RESET}\n"; return; } if ($options->{wake}) { - api_request("/services/$options->{wake}/wake", 'POST', undef, $api_key); + api_request("/services/$options->{wake}/wake", 'POST', undef, $public_key, $secret_key); print "${GREEN}Service waking: $options->{wake}${RESET}\n"; return; } if ($options->{destroy}) { - api_request("/services/$options->{destroy}", 'DELETE', undef, $api_key); + api_request("/services/$options->{destroy}", 'DELETE', undef, $public_key, $secret_key); print "${GREEN}Service destroyed: $options->{destroy}${RESET}\n"; return; } if ($options->{execute}) { my $payload = { command => $options->{command} }; - my $result = api_request("/services/$options->{execute}/execute", 'POST', $payload, $api_key); + my $result = api_request("/services/$options->{execute}/execute", 'POST', $payload, $public_key, $secret_key); print "${BLUE}$result->{stdout}${RESET}" if $result->{stdout}; print STDERR "${RED}$result->{stderr}${RESET}" if $result->{stderr}; return; @@ -328,7 +348,7 @@ sub cmd_service { if ($options->{dump_bootstrap}) { print STDERR "Fetching bootstrap script from $options->{dump_bootstrap}...\n"; my $payload = { command => 'cat /tmp/bootstrap.sh' }; - my $result = api_request("/services/$options->{dump_bootstrap}/execute", 'POST', $payload, $api_key); + my $result = api_request("/services/$options->{dump_bootstrap}/execute", 'POST', $payload, $public_key, $secret_key); if ($result->{stdout}) { my $bootstrap = $result->{stdout}; @@ -379,7 +399,7 @@ sub cmd_service { $payload->{network} = $options->{network} if $options->{network}; $payload->{vcpu} = $options->{vcpu} if $options->{vcpu}; - my $result = api_request('/services', 'POST', $payload, $api_key); + my $result = api_request('/services', 'POST', $payload, $public_key, $secret_key); print "${GREEN}Service created: ", ($result->{id} // 'N/A'), "${RESET}\n"; print "Name: ", ($result->{name} // 'N/A'), "\n"; print "URL: $result->{url}\n" if $result->{url}; @@ -405,15 +425,24 @@ sub open_browser { } sub validate_key { - my ($api_key, $should_extend) = @_; + my ($public_key, $secret_key, $should_extend) = @_; # Call /keys/validate endpoint my $url = "$PORTAL_BASE/keys/validate"; my $ua = LWP::UserAgent->new(timeout => 30); my $request = HTTP::Request->new('POST' => $url); - $request->header('Authorization' => "Bearer $api_key"); + $request->header('Authorization' => "Bearer $public_key"); $request->header('Content-Type' => 'application/json'); + # Add HMAC signature if secret_key is present + if ($secret_key) { + my $timestamp = time(); + my $sig_input = "${timestamp}:POST:/keys/validate:"; + my $signature = hmac_sha256_hex($sig_input, $secret_key); + $request->header('X-Timestamp' => $timestamp); + $request->header('X-Signature' => $signature); + } + my $response = $ua->request($request); my $result = decode_json($response->content); @@ -455,8 +484,8 @@ sub validate_key { sub cmd_key { my ($options) = @_; - my $api_key = get_api_key($options->{api_key}); - validate_key($api_key, $options->{extend}); + my ($public_key, $secret_key) = get_api_key($options->{api_key}); + validate_key($public_key, $secret_key, $options->{extend}); } sub main { diff --git a/un.pro b/un.pro index dddd2d4..efaf07c 100644 --- a/un.pro +++ b/un.pro @@ -79,15 +79,32 @@ read_file_content(Filename, Content) :- read_string(Stream, _, Content), close(Stream). -% Get API key from environment -get_api_key(ApiKey) :- - ( getenv('UNSANDBOX_API_KEY', ApiKey), - ApiKey \= '' +% Get API keys from environment (HMAC or legacy) +get_public_key(PublicKey) :- + ( getenv('UNSANDBOX_PUBLIC_KEY', PublicKey), + PublicKey \= '' -> true - ; write(user_error, 'Error: UNSANDBOX_API_KEY environment variable not set\n'), + ; getenv('UNSANDBOX_API_KEY', PublicKey), + PublicKey \= '' + -> true + ; write(user_error, 'Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY environment variable not set\n'), halt(1) ). +get_secret_key(SecretKey) :- + ( getenv('UNSANDBOX_SECRET_KEY', SecretKey), + SecretKey \= '' + -> true + ; getenv('UNSANDBOX_API_KEY', SecretKey), + SecretKey \= '' + -> true + ; SecretKey = '' + ). + +% Get API key (legacy compatibility) +get_api_key(ApiKey) :- + get_public_key(ApiKey). + % Execute command using curl execute_file(Filename) :- % Check file exists @@ -105,97 +122,108 @@ execute_file(Filename) :- halt(1) ), - % Get API key - get_api_key(ApiKey), + % Get API keys + get_public_key(PublicKey), + get_secret_key(SecretKey), - % Build and execute curl command + % Build and execute curl command with HMAC format(atom(Cmd), - 'curl -s -X POST https://api.unsandbox.com/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" --data-binary @- -o /tmp/unsandbox_resp.json < <(jq -Rs \'\'\''{language: "~w", code: .}\'\'\'\' < "~w"); jq -r ".stdout // empty" /tmp/unsandbox_resp.json | sed "s/^/\\x1b[34m/" | sed "s/$/\\x1b[0m/"; jq -r ".stderr // empty" /tmp/unsandbox_resp.json | sed "s/^/\\x1b[31m/" | sed "s/$/\\x1b[0m/" >&2; rm -f /tmp/unsandbox_resp.json', - [ApiKey, Language, Filename]), + 'BODY=$(jq -Rs \'\'\''{language: "~w", code: .}\'\'\'\' < "~w"); TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/execute:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'\'\'s/.*= //\'\'\'\'); curl -s -X POST https://api.unsandbox.com/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" -o /tmp/unsandbox_resp.json; jq -r ".stdout // empty" /tmp/unsandbox_resp.json | sed "s/^/\\x1b[34m/" | sed "s/$/\\x1b[0m/"; jq -r ".stderr // empty" /tmp/unsandbox_resp.json | sed "s/^/\\x1b[31m/" | sed "s/$/\\x1b[0m/" >&2; rm -f /tmp/unsandbox_resp.json', + [Language, Filename, SecretKey, PublicKey]), shell(Cmd, 0). % Session list session_list :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X GET https://api.unsandbox.com/sessions -H "Authorization: Bearer ~w" | jq -r \'.sessions[] | "\\(.id) \\(.shell) \\(.status) \\(.created_at)"\' 2>/dev/null || echo "No active sessions"', - [ApiKey]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/sessions:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/sessions -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r \'.sessions[] | "\\(.id) \\(.shell) \\(.status) \\(.created_at)"\' 2>/dev/null || echo "No active sessions"', + [SecretKey, PublicKey]), shell(Cmd, 0). % Session kill session_kill(SessionId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X DELETE https://api.unsandbox.com/sessions/~w -H "Authorization: Bearer ~w" >/dev/null && echo -e "\\x1b[32mSession terminated: ~w\\x1b[0m"', - [SessionId, ApiKey, SessionId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/sessions/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE https://api.unsandbox.com/sessions/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mSession terminated: ~w\\x1b[0m"', + [SessionId, SecretKey, SessionId, PublicKey, SessionId]), shell(Cmd, 0). % Service list service_list :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X GET https://api.unsandbox.com/services -H "Authorization: Bearer ~w" | jq -r \'.services[] | "\\(.id) \\(.name) \\(.status)"\' 2>/dev/null || echo "No services"', - [ApiKey]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/services:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/services -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r \'.services[] | "\\(.id) \\(.name) \\(.status)"\' 2>/dev/null || echo "No services"', + [SecretKey, PublicKey]), shell(Cmd, 0). % Service info service_info(ServiceId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X GET https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" | jq .', - [ServiceId, ApiKey]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/services/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq .', + [ServiceId, SecretKey, ServiceId, PublicKey]), shell(Cmd, 0). % Service logs service_logs(ServiceId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X GET https://api.unsandbox.com/services/~w/logs -H "Authorization: Bearer ~w" | jq -r ".logs"', - [ServiceId, ApiKey]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/services/~w/logs:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/services/~w/logs -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r ".logs"', + [ServiceId, SecretKey, ServiceId, PublicKey]), shell(Cmd, 0). % Service sleep service_sleep(ServiceId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X POST https://api.unsandbox.com/services/~w/sleep -H "Authorization: Bearer ~w" >/dev/null && echo -e "\\x1b[32mService sleeping: ~w\\x1b[0m"', - [ServiceId, ApiKey, ServiceId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/sleep:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/services/~w/sleep -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mService sleeping: ~w\\x1b[0m"', + [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), shell(Cmd, 0). % Service wake service_wake(ServiceId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X POST https://api.unsandbox.com/services/~w/wake -H "Authorization: Bearer ~w" >/dev/null && echo -e "\\x1b[32mService waking: ~w\\x1b[0m"', - [ServiceId, ApiKey, ServiceId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/wake:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/services/~w/wake -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mService waking: ~w\\x1b[0m"', + [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), shell(Cmd, 0). % Service destroy service_destroy(ServiceId) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), format(atom(Cmd), - 'curl -s -X DELETE https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" >/dev/null && echo -e "\\x1b[32mService destroyed: ~w\\x1b[0m"', - [ServiceId, ApiKey, ServiceId]), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:DELETE:/services/~w:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X DELETE https://api.unsandbox.com/services/~w -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" >/dev/null && echo -e "\\x1b[32mService destroyed: ~w\\x1b[0m"', + [ServiceId, SecretKey, ServiceId, PublicKey, ServiceId]), shell(Cmd, 0). % Service dump bootstrap service_dump_bootstrap(ServiceId, DumpFile) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), ( DumpFile = '' -> % No file specified, print to stdout format(atom(Cmd), - 'echo "Fetching bootstrap script from ~w..." >&2; RESP=$(curl -s -X POST https://api.unsandbox.com/services/~w/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -d \'\'\'\'{"command":"cat /tmp/bootstrap.sh"}\'\'\'\'); STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); if [ -n "$STDOUT" ]; then echo "$STDOUT"; else echo -e "\\x1b[31mError: Failed to fetch bootstrap\\x1b[0m" >&2; exit 1; fi', - [ServiceId, ServiceId, ApiKey]) + 'echo "Fetching bootstrap script from ~w..." >&2; BODY=\'\'\'\'{"command":"cat /tmp/bootstrap.sh"}\'\'\'\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/execute:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'\'\'s/.*= //\'\'\'\'); RESP=$(curl -s -X POST https://api.unsandbox.com/services/~w/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY"); STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); if [ -n "$STDOUT" ]; then echo "$STDOUT"; else echo -e "\\x1b[31mError: Failed to fetch bootstrap\\x1b[0m" >&2; exit 1; fi', + [ServiceId, ServiceId, SecretKey, ServiceId, PublicKey]) ; % File specified, save to file format(atom(Cmd), - 'echo "Fetching bootstrap script from ~w..." >&2; RESP=$(curl -s -X POST https://api.unsandbox.com/services/~w/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -d \'\'\'\'{"command":"cat /tmp/bootstrap.sh"}\'\'\'\'); STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); if [ -n "$STDOUT" ]; then echo "$STDOUT" > "~w" && chmod 755 "~w" && echo "Bootstrap saved to ~w"; else echo -e "\\x1b[31mError: Failed to fetch bootstrap\\x1b[0m" >&2; exit 1; fi', - [ServiceId, ServiceId, ApiKey, DumpFile, DumpFile, DumpFile]) + 'echo "Fetching bootstrap script from ~w..." >&2; BODY=\'\'\'\'{"command":"cat /tmp/bootstrap.sh"}\'\'\'\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services/~w/execute:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'\'\'s/.*= //\'\'\'\'); RESP=$(curl -s -X POST https://api.unsandbox.com/services/~w/execute -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY"); STDOUT=$(echo "$RESP" | jq -r ".stdout // empty"); if [ -n "$STDOUT" ]; then echo "$STDOUT" > "~w" && chmod 755 "~w" && echo "Bootstrap saved to ~w"; else echo -e "\\x1b[31mError: Failed to fetch bootstrap\\x1b[0m" >&2; exit 1; fi', + [ServiceId, ServiceId, SecretKey, ServiceId, PublicKey, DumpFile, DumpFile, DumpFile]) ), shell(Cmd, 0). % Service create service_create(Name, Ports, Bootstrap, ServiceType) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), % Build JSON payload ( Ports \= '' -> format(atom(PortsJson), ',"ports":[~w]', [Ports]) @@ -210,25 +238,26 @@ service_create(Name, Ports, Bootstrap, ServiceType) :- ; ServiceTypeJson = '' ), format(atom(Json), '{"name":"~w"~w~w~w}', [Name, PortsJson, BootstrapJson, ServiceTypeJson]), - % Execute curl command + % Execute curl command with HMAC format(atom(Cmd), - 'curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -d \'~w\' && echo -e "\\x1b[32mService created\\x1b[0m"', - [ApiKey, Json]), + 'BODY=\'~w\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/services:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST https://api.unsandbox.com/services -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" && echo -e "\\x1b[32mService created\\x1b[0m"', + [Json, SecretKey, PublicKey]), shell(Cmd, 0). % Key validate validate_key(Extend) :- - get_api_key(ApiKey), + get_public_key(PublicKey), + get_secret_key(SecretKey), portal_base(PortalBase), ( Extend = true -> % Build command for --extend mode format(atom(Cmd), - 'RESP=$(curl -s -X POST ~w/keys/validate -H "Content-Type: application/json" -H "Authorization: Bearer ~w"); PUBLIC_KEY=$(echo "$RESP" | jq -r ".public_key // \\"N/A\\""); xdg-open "~w/keys/extend?pk=$PUBLIC_KEY" 2>/dev/null', - [PortalBase, ApiKey, PortalBase]) + 'BODY=\'\'{}\'\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/keys/validate:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -X POST ~w/keys/validate -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY"); PUBLIC_KEY=$(echo "$RESP" | jq -r ".public_key // \\"N/A\\""); xdg-open "~w/keys/extend?pk=$PUBLIC_KEY" 2>/dev/null', + [SecretKey, PortalBase, PublicKey, PortalBase]) ; % Build command for normal validation format(atom(Cmd), - 'curl -s -X POST ~w/keys/validate -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -o /tmp/unsandbox_key_resp.json; STATUS=$?; if [ $STATUS -ne 0 ]; then echo -e "\\x1b[31mInvalid\\x1b[0m"; exit 1; fi; EXPIRED=$(jq -r ".expired // false" /tmp/unsandbox_key_resp.json); if [ "$EXPIRED" = "true" ]; then echo -e "\\x1b[31mExpired\\x1b[0m"; echo "Public Key: $(jq -r ".public_key // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Tier: $(jq -r ".tier // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Expired: $(jq -r ".expires_at // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo -e "\\x1b[33mTo renew: Visit https://unsandbox.com/keys/extend\\x1b[0m"; rm -f /tmp/unsandbox_key_resp.json; exit 1; else echo -e "\\x1b[32mValid\\x1b[0m"; echo "Public Key: $(jq -r ".public_key // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Tier: $(jq -r ".tier // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Status: $(jq -r ".status // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Expires: $(jq -r ".expires_at // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Time Remaining: $(jq -r ".time_remaining // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Rate Limit: $(jq -r ".rate_limit // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Burst: $(jq -r ".burst // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Concurrency: $(jq -r ".concurrency // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; fi; rm -f /tmp/unsandbox_key_resp.json', - [PortalBase, ApiKey]) + 'BODY=\'\'{}\'\'; TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:POST:/keys/validate:$BODY"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X POST ~w/keys/validate -H "Content-Type: application/json" -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" -d "$BODY" -o /tmp/unsandbox_key_resp.json; STATUS=$?; if [ $STATUS -ne 0 ]; then echo -e "\\x1b[31mInvalid\\x1b[0m"; exit 1; fi; EXPIRED=$(jq -r ".expired // false" /tmp/unsandbox_key_resp.json); if [ "$EXPIRED" = "true" ]; then echo -e "\\x1b[31mExpired\\x1b[0m"; echo "Public Key: $(jq -r ".public_key // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Tier: $(jq -r ".tier // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Expired: $(jq -r ".expires_at // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo -e "\\x1b[33mTo renew: Visit https://unsandbox.com/keys/extend\\x1b[0m"; rm -f /tmp/unsandbox_key_resp.json; exit 1; else echo -e "\\x1b[32mValid\\x1b[0m"; echo "Public Key: $(jq -r ".public_key // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Tier: $(jq -r ".tier // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Status: $(jq -r ".status // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Expires: $(jq -r ".expires_at // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Time Remaining: $(jq -r ".time_remaining // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Rate Limit: $(jq -r ".rate_limit // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Burst: $(jq -r ".burst // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; echo "Concurrency: $(jq -r ".concurrency // \\"N/A\\"" /tmp/unsandbox_key_resp.json)"; fi; rm -f /tmp/unsandbox_key_resp.json', + [SecretKey, PortalBase, PublicKey]) ), shell(Cmd, 0). diff --git a/un.ps1 b/un.ps1 index 3b092ec..b332709 100644 --- a/un.ps1 +++ b/un.ps1 @@ -59,24 +59,47 @@ $EXT_MAP = @{ ".raku" = "raku"; ".m" = "objc"; ".awk" = "awk" } -function Get-ApiKey { - $key = $env:UNSANDBOX_API_KEY - if (-not $key) { - Write-Error "Error: UNSANDBOX_API_KEY not set" +function Get-ApiKeys { + $publicKey = $env:UNSANDBOX_PUBLIC_KEY + $secretKey = $env:UNSANDBOX_SECRET_KEY + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if (-not $publicKey -and $env:UNSANDBOX_API_KEY) { + $publicKey = $env:UNSANDBOX_API_KEY + $secretKey = "" + } + + if (-not $publicKey) { + Write-Error "Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set" exit 1 } - return $key + return @($publicKey, $secretKey) } function Invoke-Api { param($Endpoint, $Method = "GET", $Body = $null, $BaseUrl = $null) - $apiKey = Get-ApiKey + $publicKey, $secretKey = Get-ApiKeys $headers = @{ - "Authorization" = "Bearer $apiKey" + "Authorization" = "Bearer $publicKey" "Content-Type" = "application/json" } + # Add HMAC signature if secret key exists + if ($secretKey) { + $timestamp = [int][double]::Parse((Get-Date -UFormat %s)) + $bodyContent = if ($Body) { $Body } else { "" } + $sigInput = "${timestamp}:${Method}:${Endpoint}:${bodyContent}" + + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secretKey) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($sigInput)) + $signature = [System.BitConverter]::ToString($hash).Replace("-", "").ToLower() + + $headers["X-Timestamp"] = $timestamp.ToString() + $headers["X-Signature"] = $signature + } + $base = if ($BaseUrl) { $BaseUrl } else { $API_BASE } $uri = "$base$Endpoint" diff --git a/un.py b/un.py index f8f4002..6943bc3 100644 --- a/un.py +++ b/un.py @@ -59,6 +59,9 @@ import argparse import urllib.request import urllib.error import webbrowser +import hmac +import hashlib +import time API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" @@ -86,16 +89,28 @@ EXT_MAP = { } -def get_api_key(args_key=None): - """Get API key from args or environment""" - key = args_key or os.environ.get("UNSANDBOX_API_KEY") - if not key: - print(f"{RED}Error: UNSANDBOX_API_KEY not set{RESET}", file=sys.stderr) - sys.exit(1) - return key +def get_api_keys(args_key=None): + """Get API keys from args or environment. Returns (public_key, secret_key).""" + # Try new split key format first + public_key = os.environ.get("UNSANDBOX_PUBLIC_KEY") + secret_key = os.environ.get("UNSANDBOX_SECRET_KEY") + + # Fall back to old single key format for backwards compatibility + if not public_key or not secret_key: + old_key = args_key or os.environ.get("UNSANDBOX_API_KEY") + if old_key: + # Old format: use the key as secret, derive public from it or use as-is + public_key = old_key + secret_key = old_key + else: + print(f"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}", file=sys.stderr) + print(f"{RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility){RESET}", file=sys.stderr) + sys.exit(1) + + return public_key, secret_key -def detect_language(filename): +def detect_language(filename, exit_on_error=True): """Detect language from file extension""" ext = os.path.splitext(filename)[1].lower() lang = EXT_MAP.get(ext) @@ -114,22 +129,52 @@ def detect_language(filename): if 'php' in first_line: return 'php' except: pass - print(f"{RED}Error: Cannot detect language for {filename}{RESET}", file=sys.stderr) - sys.exit(1) + if exit_on_error: + print(f"{RED}Error: Cannot detect language for {filename}{RESET}", file=sys.stderr) + sys.exit(1) + return None return lang -def api_request(endpoint, method="GET", data=None, api_key=None): - """Make API request and return response""" +def read_file(filepath): + """Read file contents - helper for tests""" + with open(filepath, 'r') as f: + return f.read() + + +def execute_code(language, code, public_key=None, secret_key=None): + """Execute code and return result - helper for tests""" + if not public_key: + public_key, secret_key = get_api_keys() + return api_request("/execute", method="POST", data={"language": language, "code": code}, public_key=public_key, secret_key=secret_key) + + +def api_request(endpoint, method="GET", data=None, public_key=None, secret_key=None): + """Make API request with HMAC authentication""" url = f"{API_BASE}{endpoint}" + + # Prepare body + body = json.dumps(data) if data else "" + + # Generate HMAC signature + timestamp = str(int(time.time())) + signature_input = f"{timestamp}:{method}:{endpoint}:{body}" + signature = hmac.new( + secret_key.encode('utf-8'), + signature_input.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + headers = { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {public_key}", + "X-Timestamp": timestamp, + "X-Signature": signature, "Content-Type": "application/json" } req = urllib.request.Request(url, method=method, headers=headers) if data: - req.data = json.dumps(data).encode('utf-8') + req.data = body.encode('utf-8') try: with urllib.request.urlopen(req, timeout=300) as resp: @@ -145,7 +190,7 @@ def api_request(endpoint, method="GET", data=None, api_key=None): def cmd_execute(args): """Execute source code""" - api_key = get_api_key(args.api_key) + public_key, secret_key = get_api_keys(args.api_key) # Read source file try: @@ -199,7 +244,7 @@ def cmd_execute(args): payload["vcpu"] = args.vcpu # Execute - result = api_request("/execute", method="POST", data=payload, api_key=api_key) + result = api_request("/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) # Print output if result.get("stdout"): @@ -225,10 +270,10 @@ def cmd_execute(args): def cmd_session(args): """Manage interactive sessions""" - api_key = get_api_key(args.api_key) + public_key, secret_key = get_api_keys(args.api_key) if args.list: - result = api_request("/sessions", api_key=api_key) + result = api_request("/sessions", public_key=public_key, secret_key=secret_key) sessions = result.get("sessions", []) if not sessions: print("No active sessions") @@ -239,7 +284,7 @@ def cmd_session(args): return if args.kill: - result = api_request(f"/sessions/{args.kill}", method="DELETE", api_key=api_key) + result = api_request(f"/sessions/{args.kill}", method="DELETE", public_key=public_key, secret_key=secret_key) print(f"{GREEN}Session terminated: {args.kill}{RESET}") return @@ -264,16 +309,30 @@ def cmd_session(args): payload["audit"] = True print(f"{YELLOW}Creating session...{RESET}") - result = api_request("/sessions", method="POST", data=payload, api_key=api_key) + result = api_request("/sessions", method="POST", data=payload, public_key=public_key, secret_key=secret_key) print(f"{GREEN}Session created: {result.get('id', 'N/A')}{RESET}") print(f"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}") -def validate_key(api_key, extend=False): +def validate_key(public_key, secret_key, extend=False): """Validate API key and display information""" url = f"{PORTAL_BASE}/keys/validate" + + # Generate HMAC signature for portal request + timestamp = str(int(time.time())) + endpoint = "/keys/validate" + body = "" + signature_input = f"{timestamp}:POST:{endpoint}:{body}" + signature = hmac.new( + secret_key.encode('utf-8'), + signature_input.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + headers = { - "Authorization": f"Bearer {api_key}", + "Authorization": f"Bearer {public_key}", + "X-Timestamp": timestamp, + "X-Signature": signature, "Content-Type": "application/json" } @@ -332,16 +391,16 @@ def validate_key(api_key, extend=False): def cmd_key(args): """Validate API key""" - api_key = get_api_key(args.key) - validate_key(api_key, extend=args.extend) + public_key, secret_key = get_api_keys(args.key) + validate_key(public_key, secret_key, extend=args.extend) def cmd_service(args): """Manage persistent services""" - api_key = get_api_key(args.api_key) + public_key, secret_key = get_api_keys(args.api_key) if args.list: - result = api_request("/services", api_key=api_key) + result = api_request("/services", public_key=public_key, secret_key=secret_key) services = result.get("services", []) if not services: print("No services") @@ -354,38 +413,38 @@ def cmd_service(args): return if args.info: - result = api_request(f"/services/{args.info}", api_key=api_key) + result = api_request(f"/services/{args.info}", public_key=public_key, secret_key=secret_key) print(json.dumps(result, indent=2)) return if args.logs: - result = api_request(f"/services/{args.logs}/logs", api_key=api_key) + result = api_request(f"/services/{args.logs}/logs", public_key=public_key, secret_key=secret_key) print(result.get("logs", "")) return if args.tail: - result = api_request(f"/services/{args.tail}/logs?lines=9000", api_key=api_key) + result = api_request(f"/services/{args.tail}/logs?lines=9000", public_key=public_key, secret_key=secret_key) print(result.get("logs", "")) return if args.sleep: - result = api_request(f"/services/{args.sleep}/sleep", method="POST", api_key=api_key) + result = api_request(f"/services/{args.sleep}/sleep", method="POST", public_key=public_key, secret_key=secret_key) print(f"{GREEN}Service sleeping: {args.sleep}{RESET}") return if args.wake: - result = api_request(f"/services/{args.wake}/wake", method="POST", api_key=api_key) + result = api_request(f"/services/{args.wake}/wake", method="POST", public_key=public_key, secret_key=secret_key) print(f"{GREEN}Service waking: {args.wake}{RESET}") return if args.destroy: - result = api_request(f"/services/{args.destroy}", method="DELETE", api_key=api_key) + result = api_request(f"/services/{args.destroy}", method="DELETE", public_key=public_key, secret_key=secret_key) print(f"{GREEN}Service destroyed: {args.destroy}{RESET}") return if args.execute: payload = {"command": args.command} - result = api_request(f"/services/{args.execute}/execute", method="POST", data=payload, api_key=api_key) + result = api_request(f"/services/{args.execute}/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) if result.get("stdout"): print(f"{BLUE}{result['stdout']}{RESET}", end='') if result.get("stderr"): @@ -395,7 +454,7 @@ def cmd_service(args): if args.dump_bootstrap: print(f"Fetching bootstrap script from {args.dump_bootstrap}...", file=sys.stderr) payload = {"command": "cat /tmp/bootstrap.sh"} - result = api_request(f"/services/{args.dump_bootstrap}/execute", method="POST", data=payload, api_key=api_key) + result = api_request(f"/services/{args.dump_bootstrap}/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) if result.get("stdout"): bootstrap = result["stdout"] @@ -438,7 +497,7 @@ def cmd_service(args): if args.vcpu: payload["vcpu"] = args.vcpu - result = api_request("/services", method="POST", data=payload, api_key=api_key) + result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key) print(f"{GREEN}Service created: {result.get('id', 'N/A')}{RESET}") print(f"Name: {result.get('name', 'N/A')}") if result.get('url'): diff --git a/un.r b/un.r index 519e42c..dd25ac8 100644 --- a/un.r +++ b/un.r @@ -38,6 +38,7 @@ library(httr) library(jsonlite) +library(digest) # Extension to language mapping ext_map <- list( @@ -75,28 +76,53 @@ detect_language <- function(filename) { return(lang) } -get_api_key <- function(args_key = NULL) { - key <- if (!is.null(args_key)) args_key else Sys.getenv("UNSANDBOX_API_KEY") - if (key == "") { - cat(sprintf("%sError: UNSANDBOX_API_KEY not set%s\n", RED, RESET), file = stderr()) +get_api_keys <- function(args_key = NULL) { + public_key <- Sys.getenv("UNSANDBOX_PUBLIC_KEY") + secret_key <- Sys.getenv("UNSANDBOX_SECRET_KEY") + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if (public_key == "" && Sys.getenv("UNSANDBOX_API_KEY") != "") { + public_key <- Sys.getenv("UNSANDBOX_API_KEY") + secret_key <- "" + } + + if (public_key == "") { + cat(sprintf("%sError: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set%s\n", RED, RESET), file = stderr()) quit(status = 1) } - return(key) + return(list(public_key = public_key, secret_key = secret_key)) } -api_request <- function(endpoint, api_key, method = "GET", data = NULL) { +api_request <- function(endpoint, public_key, secret_key, method = "GET", data = NULL) { url <- paste0(API_BASE, endpoint) headers <- add_headers( `Content-Type` = "application/json", - `Authorization` = paste("Bearer", api_key) + `Authorization` = paste("Bearer", public_key) ) + body_content <- "" + if (!is.null(data)) { + body_content <- toJSON(data, auto_unbox = TRUE) + } + + # Add HMAC signature if secret_key is present + if (secret_key != "") { + timestamp <- as.integer(Sys.time()) + sig_input <- paste0(timestamp, ":", method, ":", endpoint, ":", body_content) + signature <- hmac(sig_input, secret_key, algo = "sha256") + headers <- add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", public_key), + `X-Timestamp` = as.character(timestamp), + `X-Signature` = signature + ) + } + tryCatch({ if (method == "GET") { response <- GET(url, headers, timeout(300)) } else if (method == "POST") { - body <- if (!is.null(data)) toJSON(data, auto_unbox = TRUE) else "" - response <- POST(url, headers, body = body, encode = "raw", timeout(300)) + response <- POST(url, headers, body = body_content, encode = "raw", timeout(300)) } else if (method == "DELETE") { response <- DELETE(url, headers, timeout(300)) } else { @@ -112,7 +138,9 @@ api_request <- function(endpoint, api_key, method = "GET", data = NULL) { } cmd_execute <- function(args) { - api_key <- get_api_key(args$api_key) + keys <- get_api_keys(args$api_key) + public_key <- keys$public_key + secret_key <- keys$secret_key filename <- args$source_file if (!file.exists(filename)) { @@ -175,7 +203,7 @@ cmd_execute <- function(args) { } # Execute - result <- api_request("/execute", api_key, method = "POST", data = payload) + result <- api_request("/execute", public_key, secret_key, method = "POST", data = payload) # Print output if (!is.null(result$stdout) && result$stdout != "") { @@ -204,10 +232,12 @@ cmd_execute <- function(args) { } cmd_session <- function(args) { - api_key <- get_api_key(args$api_key) + keys <- get_api_keys(args$api_key) + public_key <- keys$public_key + secret_key <- keys$secret_key if (!is.null(args$list) && args$list) { - result <- api_request("/sessions", api_key) + result <- api_request("/sessions", public_key, secret_key) sessions <- if (!is.null(result$sessions)) result$sessions else list() if (length(sessions) == 0) { cat("No active sessions\n") @@ -225,7 +255,7 @@ cmd_session <- function(args) { } if (!is.null(args$kill)) { - result <- api_request(paste0("/sessions/", args$kill), api_key, method = "DELETE") + result <- api_request(paste0("/sessions/", args$kill), public_key, secret_key, method = "DELETE") cat(sprintf("%sSession terminated: %s%s\n", GREEN, args$kill, RESET)) return() } @@ -235,16 +265,31 @@ cmd_session <- function(args) { } cmd_key <- function(args) { - api_key <- get_api_key(args$api_key) + keys <- get_api_keys(args$api_key) + public_key <- keys$public_key + secret_key <- keys$secret_key if (!is.null(args$extend) && args$extend) { # First validate to get public_key url <- paste0(PORTAL_BASE, "/keys/validate") headers <- add_headers( `Content-Type` = "application/json", - `Authorization` = paste("Bearer", api_key) + `Authorization` = paste("Bearer", public_key) ) + # Add HMAC signature if secret_key is present + if (secret_key != "") { + timestamp <- as.integer(Sys.time()) + sig_input <- paste0(timestamp, ":POST:/keys/validate:") + signature <- hmac(sig_input, secret_key, algo = "sha256") + headers <- add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", public_key), + `X-Timestamp` = as.character(timestamp), + `X-Signature` = signature + ) + } + tryCatch({ response <- POST(url, headers, encode = "json", timeout(10)) result <- fromJSON(content(response, "text", encoding = "UTF-8")) @@ -268,9 +313,22 @@ cmd_key <- function(args) { url <- paste0(PORTAL_BASE, "/keys/validate") headers <- add_headers( `Content-Type` = "application/json", - `Authorization` = paste("Bearer", api_key) + `Authorization` = paste("Bearer", public_key) ) + # Add HMAC signature if secret_key is present + if (secret_key != "") { + timestamp <- as.integer(Sys.time()) + sig_input <- paste0(timestamp, ":POST:/keys/validate:") + signature <- hmac(sig_input, secret_key, algo = "sha256") + headers <- add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", public_key), + `X-Timestamp` = as.character(timestamp), + `X-Signature` = signature + ) + } + tryCatch({ response <- POST(url, headers, encode = "json", timeout(10)) result <- fromJSON(content(response, "text", encoding = "UTF-8")) @@ -302,10 +360,12 @@ cmd_key <- function(args) { } cmd_service <- function(args) { - api_key <- get_api_key(args$api_key) + keys <- get_api_keys(args$api_key) + public_key <- keys$public_key + secret_key <- keys$secret_key if (!is.null(args$list) && args$list) { - result <- api_request("/services", api_key) + result <- api_request("/services", public_key, secret_key) services <- if (!is.null(result$services)) result$services else list() if (length(services) == 0) { cat("No services\n") @@ -325,31 +385,31 @@ cmd_service <- function(args) { } if (!is.null(args$info)) { - result <- api_request(paste0("/services/", args$info), api_key) + result <- api_request(paste0("/services/", args$info), public_key, secret_key) cat(toJSON(result, pretty = TRUE, auto_unbox = TRUE), "\n") return() } if (!is.null(args$logs)) { - result <- api_request(paste0("/services/", args$logs, "/logs"), api_key) + result <- api_request(paste0("/services/", args$logs, "/logs"), public_key, secret_key) cat(if (!is.null(result$logs)) result$logs else "", "\n") return() } if (!is.null(args$sleep)) { - result <- api_request(paste0("/services/", args$sleep, "/sleep"), api_key, method = "POST") + result <- api_request(paste0("/services/", args$sleep, "/sleep"), public_key, secret_key, method = "POST") cat(sprintf("%sService sleeping: %s%s\n", GREEN, args$sleep, RESET)) return() } if (!is.null(args$wake)) { - result <- api_request(paste0("/services/", args$wake, "/wake"), api_key, method = "POST") + result <- api_request(paste0("/services/", args$wake, "/wake"), public_key, secret_key, method = "POST") cat(sprintf("%sService waking: %s%s\n", GREEN, args$wake, RESET)) return() } if (!is.null(args$destroy)) { - result <- api_request(paste0("/services/", args$destroy), api_key, method = "DELETE") + result <- api_request(paste0("/services/", args$destroy), public_key, secret_key, method = "DELETE") cat(sprintf("%sService destroyed: %s%s\n", GREEN, args$destroy, RESET)) return() } @@ -357,7 +417,7 @@ cmd_service <- function(args) { if (!is.null(args$dump_bootstrap)) { cat(sprintf("Fetching bootstrap script from %s...\n", args$dump_bootstrap), file = stderr()) payload <- list(command = "cat /tmp/bootstrap.sh") - result <- api_request(paste0("/services/", args$dump_bootstrap, "/execute"), api_key, method = "POST", data = payload) + result <- api_request(paste0("/services/", args$dump_bootstrap, "/execute"), public_key, secret_key, method = "POST", data = payload) if (!is.null(result$stdout) && result$stdout != "") { bootstrap <- result$stdout @@ -415,7 +475,7 @@ cmd_service <- function(args) { payload$vcpu <- args$vcpu } - result <- api_request("/services", api_key, method = "POST", data = payload) + result <- api_request("/services", public_key, secret_key, method = "POST", data = payload) cat(sprintf("%sService created: %s%s\n", GREEN, if (!is.null(result$id)) result$id else "N/A", RESET)) cat(sprintf("Name: %s\n", if (!is.null(result$name)) result$name else "N/A")) if (!is.null(result$url)) { diff --git a/un.raku b/un.raku index 2b8f72b..1d93c9a 100644 --- a/un.raku +++ b/un.raku @@ -40,6 +40,7 @@ # Full-featured CLI matching un.c/un.py capabilities use JSON::Fast; +use Digest::SHA; constant $API_BASE = "https://api.unsandbox.com"; constant $PORTAL_BASE = "https://unsandbox.com"; @@ -66,13 +67,21 @@ my %EXT_MAP = ( m => 'objc' ); -sub get-api-key() { - my $key = %*ENV; - unless $key { - note "{$RED}Error: UNSANDBOX_API_KEY not set{$RESET}"; +sub get-api-keys() { + my $public-key = %*ENV // ''; + my $secret-key = %*ENV // ''; + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if !$public-key && %*ENV { + $public-key = %*ENV; + $secret-key = ''; + } + + unless $public-key { + note "{$RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set{$RESET}"; exit 1; } - return $key; + return ($public-key, $secret-key); } sub detect-language(Str $filename --> Str) { @@ -98,9 +107,10 @@ sub detect-language(Str $filename --> Str) { exit 1; } -sub api-request(Str $endpoint, Str $method, %data?, Str :$api-key!) { +sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$secret-key!) { my $url = $API_BASE ~ $endpoint; my @args = 'curl', '-s'; + my $body = ''; if $method eq 'GET' { @args.append: '-X', 'GET'; @@ -110,11 +120,22 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$api-key!) { @args.append: '-X', 'POST'; @args.append: '-H', 'Content-Type: application/json'; if %data { - @args.append: '-d', to-json(%data); + $body = to-json(%data); + @args.append: '-d', $body; } } - @args.append: '-H', "Authorization: Bearer $api-key"; + @args.append: '-H', "Authorization: Bearer $public-key"; + + # Add HMAC signature if secret-key is present + if $secret-key { + my $timestamp = now.Int; + my $sig-input = "{$timestamp}:{$method}:{$endpoint}:{$body}"; + my $signature = hmac-hex($sig-input, $secret-key, &sha256); + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + } + @args.append: $url; my $proc = run |@args, :out, :err; @@ -131,7 +152,7 @@ sub api-request(Str $endpoint, Str $method, %data?, Str :$api-key!) { } sub cmd-execute(@args) { - my $api-key = get-api-key(); + my ($public-key, $secret-key) = get-api-keys(); my $source-file = ''; my %env-vars; my @input-files; @@ -218,7 +239,7 @@ sub cmd-execute(@args) { %payload = $vcpu if $vcpu > 0; # Execute - my %result = api-request('/execute', 'POST', %payload, :$api-key); + my %result = api-request('/execute', 'POST', %payload, :$public-key, :$secret-key); # Print output if %result { @@ -245,7 +266,7 @@ sub cmd-execute(@args) { } sub cmd-session(@args) { - my $api-key = get-api-key(); + my ($public-key, $secret-key) = get-api-keys(); my $list-mode = False; my $kill-id = ''; my $shell = ''; @@ -280,7 +301,7 @@ sub cmd-session(@args) { } if $list-mode { - my %result = api-request('/sessions', 'GET', :$api-key); + my %result = api-request('/sessions', 'GET', :$public-key, :$secret-key); my @sessions = %result.list; unless @sessions { say "No active sessions"; @@ -295,7 +316,7 @@ sub cmd-session(@args) { } if $kill-id { - api-request("/sessions/$kill-id", 'DELETE', :$api-key); + api-request("/sessions/$kill-id", 'DELETE', :$public-key, :$secret-key); say "{$GREEN}Session terminated: $kill-id{$RESET}"; return; } @@ -306,13 +327,13 @@ sub cmd-session(@args) { %payload = $vcpu if $vcpu > 0; say "{$YELLOW}Creating session...{$RESET}"; - my %result = api-request('/sessions', 'POST', %payload, :$api-key); + my %result = api-request('/sessions', 'POST', %payload, :$public-key, :$secret-key); say "{$GREEN}Session created: {%result}{$RESET}"; say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}"; } sub cmd-service(@args) { - my $api-key = get-api-key(); + my ($public-key, $secret-key) = get-api-keys(); my $list-mode = False; my $info-id = ''; my $logs-id = ''; @@ -392,7 +413,7 @@ sub cmd-service(@args) { } if $list-mode { - my %result = api-request('/services', 'GET', :$api-key); + my %result = api-request('/services', 'GET', :$public-key, :$secret-key); my @services = %result.list; unless @services { say "No services"; @@ -409,31 +430,31 @@ sub cmd-service(@args) { } if $info-id { - my %result = api-request("/services/$info-id", 'GET', :$api-key); + my %result = api-request("/services/$info-id", 'GET', :$public-key, :$secret-key); say to-json(%result, :pretty); return; } if $logs-id { - my %result = api-request("/services/$logs-id/logs", 'GET', :$api-key); + my %result = api-request("/services/$logs-id/logs", 'GET', :$public-key, :$secret-key); say %result; return; } if $sleep-id { - api-request("/services/$sleep-id/sleep", 'POST', :$api-key); + api-request("/services/$sleep-id/sleep", 'POST', :$public-key, :$secret-key); say "{$GREEN}Service sleeping: $sleep-id{$RESET}"; return; } if $wake-id { - api-request("/services/$wake-id/wake", 'POST', :$api-key); + api-request("/services/$wake-id/wake", 'POST', :$public-key, :$secret-key); say "{$GREEN}Service waking: $wake-id{$RESET}"; return; } if $destroy-id { - api-request("/services/$destroy-id", 'DELETE', :$api-key); + api-request("/services/$destroy-id", 'DELETE', :$public-key, :$secret-key); say "{$GREEN}Service destroyed: $destroy-id{$RESET}"; return; } @@ -441,7 +462,7 @@ sub cmd-service(@args) { if $dump-bootstrap-id { note "Fetching bootstrap script from $dump-bootstrap-id..."; my %payload = command => "cat /tmp/bootstrap.sh"; - my %result = api-request("/services/$dump-bootstrap-id/execute", 'POST', %payload, :$api-key); + my %result = api-request("/services/$dump-bootstrap-id/execute", 'POST', %payload, :$public-key, :$secret-key); if %result && %result ne '' { my $bootstrap = %result; @@ -485,7 +506,7 @@ sub cmd-service(@args) { %payload = $network if $network; %payload = $vcpu if $vcpu > 0; - my %result = api-request('/services', 'POST', %payload, :$api-key); + my %result = api-request('/services', 'POST', %payload, :$public-key, :$secret-key); say "{$GREEN}Service created: {%result}{$RESET}"; say "Name: {%result}"; say "URL: {%result}" if %result; @@ -497,13 +518,22 @@ sub cmd-service(@args) { } sub validate-key(Bool $extend) { - my $api-key = get-api-key(); + my ($public-key, $secret-key) = get-api-keys(); # Build curl command my @args = 'curl', '-s', '-X', 'POST'; @args.append: "$PORTAL_BASE/keys/validate"; @args.append: '-H', 'Content-Type: application/json'; - @args.append: '-H', "Authorization: Bearer $api-key"; + @args.append: '-H', "Authorization: Bearer $public-key"; + + # Add HMAC signature if secret-key is present + if $secret-key { + my $timestamp = now.Int; + my $sig-input = "{$timestamp}:POST:/keys/validate:"; + my $signature = hmac-hex($sig-input, $secret-key, &sha256); + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + } my $proc = run |@args, :out, :err; my $body = $proc.out.slurp; diff --git a/un.rb b/un.rb index 10ab283..ffa33cd 100644 --- a/un.rb +++ b/un.rb @@ -80,13 +80,23 @@ EXT_MAP = { '.tcl' => 'tcl', '.raku' => 'raku', '.m' => 'objc' }.freeze -def get_api_key(args_key = nil) - key = args_key || ENV['UNSANDBOX_API_KEY'] - unless key - warn "#{RED}Error: UNSANDBOX_API_KEY not set#{RESET}" - exit 1 +def get_api_keys(args_key = nil) + public_key = ENV['UNSANDBOX_PUBLIC_KEY'] + secret_key = ENV['UNSANDBOX_SECRET_KEY'] + + unless public_key && secret_key + old_key = args_key || ENV['UNSANDBOX_API_KEY'] + if old_key + public_key = old_key + secret_key = old_key + else + warn "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" + warn "#{RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)#{RESET}" + exit 1 + end end - key + + { public_key: public_key, secret_key: secret_key } end def detect_language(filename) @@ -112,12 +122,19 @@ def detect_language(filename) lang end -def api_request(endpoint, method: 'GET', data: nil, api_key:) +def api_request(endpoint, method: 'GET', data: nil, keys:) + require 'openssl' + uri = URI("#{API_BASE}#{endpoint}") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.read_timeout = 300 + timestamp = Time.now.to_i.to_s + body = data ? JSON.generate(data) : '' + message = "#{timestamp}:#{method}:#{uri.path}#{uri.query ? "?#{uri.query}" : ''}:#{body}" + signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message) + request = case method when 'GET' then Net::HTTP::Get.new(uri) when 'POST' then Net::HTTP::Post.new(uri) @@ -125,9 +142,11 @@ def api_request(endpoint, method: 'GET', data: nil, api_key:) else raise "Unknown method: #{method}" end - request['Authorization'] = "Bearer #{api_key}" + request['Authorization'] = "Bearer #{keys[:public_key]}" + request['X-Timestamp'] = timestamp + request['X-Signature'] = signature request['Content-Type'] = 'application/json' - request.body = JSON.generate(data) if data + request.body = body if data response = http.request(request) unless response.is_a?(Net::HTTPSuccess) @@ -142,7 +161,7 @@ rescue => e end def cmd_execute(options) - api_key = get_api_key(options[:api_key]) + keys = get_api_keys(options[:api_key]) unless File.exist?(options[:source_file]) warn "#{RED}Error: File not found: #{options[:source_file]}#{RESET}" @@ -181,7 +200,7 @@ def cmd_execute(options) payload[:network] = options[:network] if options[:network] payload[:vcpu] = options[:vcpu] if options[:vcpu] - result = api_request('/execute', method: 'POST', data: payload, api_key: api_key) + result = api_request('/execute', method: 'POST', data: payload, keys: keys) print "#{BLUE}#{result['stdout']}#{RESET}" if result['stdout'] $stderr.print "#{RED}#{result['stderr']}#{RESET}" if result['stderr'] @@ -203,10 +222,10 @@ def cmd_execute(options) end def cmd_session(options) - api_key = get_api_key(options[:api_key]) + keys = get_api_keys(options[:api_key]) if options[:list] - result = api_request('/sessions', api_key: api_key) + result = api_request('/sessions', keys: keys) sessions = result['sessions'] || [] if sessions.empty? puts 'No active sessions' @@ -222,7 +241,7 @@ def cmd_session(options) end if options[:kill] - api_request("/sessions/#{options[:kill]}", method: 'DELETE', api_key: api_key) + api_request("/sessions/#{options[:kill]}", method: 'DELETE', keys: keys) puts "#{GREEN}Session terminated: #{options[:kill]}#{RESET}" return end @@ -241,19 +260,28 @@ def cmd_session(options) payload[:audit] = true if options[:audit] puts "#{YELLOW}Creating session...#{RESET}" - result = api_request('/sessions', method: 'POST', data: payload, api_key: api_key) + result = api_request('/sessions', method: 'POST', data: payload, keys: keys) puts "#{GREEN}Session created: #{result['id'] || 'N/A'}#{RESET}" puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}" end -def validate_key(api_key) +def validate_key(keys) + require 'openssl' + uri = URI("#{PORTAL_BASE}/keys/validate") http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true http.read_timeout = 30 + timestamp = Time.now.to_i.to_s + body = '' + message = "#{timestamp}:POST:#{uri.path}:#{body}" + signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message) + request = Net::HTTP::Post.new(uri) - request['Authorization'] = "Bearer #{api_key}" + request['Authorization'] = "Bearer #{keys[:public_key]}" + request['X-Timestamp'] = timestamp + request['X-Signature'] = signature request['Content-Type'] = 'application/json' response = http.request(request) @@ -308,10 +336,10 @@ def open_browser(url) end def cmd_key(options) - api_key = get_api_key(options[:api_key]) + keys = get_api_keys(options[:api_key]) if options[:extend] - result = validate_key(api_key) + result = validate_key(keys) public_key = result['public_key'] if public_key url = "#{PORTAL_BASE}/keys/extend?pk=#{public_key}" @@ -322,15 +350,15 @@ def cmd_key(options) exit 1 end else - validate_key(api_key) + validate_key(keys) end end def cmd_service(options) - api_key = get_api_key(options[:api_key]) + keys = get_api_keys(options[:api_key]) if options[:list] - result = api_request('/services', api_key: api_key) + result = api_request('/services', keys: keys) services = result['services'] || [] if services.empty? puts 'No services' @@ -348,44 +376,44 @@ def cmd_service(options) end if options[:info] - result = api_request("/services/#{options[:info]}", api_key: api_key) + result = api_request("/services/#{options[:info]}", keys: keys) puts JSON.pretty_generate(result) return end if options[:logs] - result = api_request("/services/#{options[:logs]}/logs", api_key: api_key) + result = api_request("/services/#{options[:logs]}/logs", keys: keys) puts result['logs'] || '' return end if options[:tail] - result = api_request("/services/#{options[:tail]}/logs?lines=9000", api_key: api_key) + result = api_request("/services/#{options[:tail]}/logs?lines=9000", keys: keys) puts result['logs'] || '' return end if options[:sleep] - api_request("/services/#{options[:sleep]}/sleep", method: 'POST', api_key: api_key) + api_request("/services/#{options[:sleep]}/sleep", method: 'POST', keys: keys) puts "#{GREEN}Service sleeping: #{options[:sleep]}#{RESET}" return end if options[:wake] - api_request("/services/#{options[:wake]}/wake", method: 'POST', api_key: api_key) + api_request("/services/#{options[:wake]}/wake", method: 'POST', keys: keys) puts "#{GREEN}Service waking: #{options[:wake]}#{RESET}" return end if options[:destroy] - api_request("/services/#{options[:destroy]}", method: 'DELETE', api_key: api_key) + api_request("/services/#{options[:destroy]}", method: 'DELETE', keys: keys) puts "#{GREEN}Service destroyed: #{options[:destroy]}#{RESET}" return end if options[:execute] payload = { command: options[:command] } - result = api_request("/services/#{options[:execute]}/execute", method: 'POST', data: payload, api_key: api_key) + result = api_request("/services/#{options[:execute]}/execute", method: 'POST', data: payload, keys: keys) print "#{BLUE}#{result['stdout']}#{RESET}" if result['stdout'] $stderr.print "#{RED}#{result['stderr']}#{RESET}" if result['stderr'] return @@ -394,7 +422,7 @@ def cmd_service(options) if options[:dump_bootstrap] warn "Fetching bootstrap script from #{options[:dump_bootstrap]}..." payload = { command: 'cat /tmp/bootstrap.sh' } - result = api_request("/services/#{options[:dump_bootstrap]}/execute", method: 'POST', data: payload, api_key: api_key) + result = api_request("/services/#{options[:dump_bootstrap]}/execute", method: 'POST', data: payload, keys: keys) if result['stdout'] bootstrap = result['stdout'] @@ -434,7 +462,7 @@ def cmd_service(options) payload[:network] = options[:network] if options[:network] payload[:vcpu] = options[:vcpu] if options[:vcpu] - result = api_request('/services', method: 'POST', data: payload, api_key: api_key) + result = api_request('/services', method: 'POST', data: payload, keys: keys) puts "#{GREEN}Service created: #{result['id'] || 'N/A'}#{RESET}" puts "Name: #{result['name'] || 'N/A'}" puts "URL: #{result['url']}" if result['url'] diff --git a/un.rs b/un.rs index a342313..69cdae3 100644 --- a/un.rs +++ b/un.rs @@ -49,6 +49,7 @@ use std::fs; use std::path::Path; use std::process::{self, Command}; use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; const API_BASE: &str = "https://api.unsandbox.com"; const PORTAL_BASE: &str = "https://unsandbox.com"; @@ -109,14 +110,38 @@ fn detect_language(filename: &str) -> Option<&'static str> { } } -fn get_api_key(key_arg: Option<&str>) -> String { - if let Some(k) = key_arg { - return k.to_string(); +fn get_api_keys(key_arg: Option<&str>) -> (String, String) { + let public_key = env::var("UNSANDBOX_PUBLIC_KEY").ok(); + let secret_key = env::var("UNSANDBOX_SECRET_KEY").ok(); + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if public_key.is_none() || secret_key.is_none() { + let fallback_key = if let Some(k) = key_arg { + k.to_string() + } else { + env::var("UNSANDBOX_API_KEY").unwrap_or_else(|_| { + eprintln!("{}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat){}", RED, RESET); + process::exit(1); + }) + }; + return (fallback_key.clone(), fallback_key); } - env::var("UNSANDBOX_API_KEY").unwrap_or_else(|_| { - eprintln!("{}Error: UNSANDBOX_API_KEY not set{}", RED, RESET); - process::exit(1); - }) + + (public_key.unwrap(), secret_key.unwrap()) +} + +fn compute_hmac(secret_key: &str, timestamp: &str, method: &str, path: &str, body: &str) -> String { + use std::process::Command; + let message = format!("{}:{}:{}:{}", timestamp, method, path, body); + + // Use openssl for HMAC-SHA256 + let output = Command::new("sh") + .arg("-c") + .arg(format!("printf '%s' '{}' | openssl dgst -sha256 -hmac '{}' | cut -d' ' -f2", message, secret_key)) + .output() + .expect("Failed to compute HMAC"); + + String::from_utf8_lossy(&output.stdout).trim().to_string() } fn escape_json(s: &str) -> String { @@ -163,8 +188,18 @@ fn extract_json_int(json: &str, key: &str) -> i32 { 1 } -fn api_request(endpoint: &str, method: &str, body: Option<&str>, api_key: &str) -> String { +fn api_request(endpoint: &str, method: &str, body: Option<&str>, public_key: &str, secret_key: &str) -> String { let url = format!("{}{}", API_BASE, endpoint); + let body_str = body.unwrap_or(""); + + // Compute HMAC signature + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + let signature = compute_hmac(secret_key, ×tamp, method, endpoint, body_str); + let mut cmd = Command::new("curl"); cmd.arg("-s") .arg("-X") @@ -173,7 +208,11 @@ fn api_request(endpoint: &str, method: &str, body: Option<&str>, api_key: &str) .arg("-H") .arg("Content-Type: application/json") .arg("-H") - .arg(format!("Authorization: Bearer {}", api_key)); + .arg(format!("Authorization: Bearer {}", public_key)) + .arg("-H") + .arg(format!("X-Timestamp: {}", timestamp)) + .arg("-H") + .arg(format!("X-Signature: {}", signature)); if let Some(b) = body { cmd.arg("-d").arg(b); @@ -200,7 +239,8 @@ fn cmd_execute( output_dir: Option<&str>, network: Option<&str>, vcpu: Option, - api_key: &str, + public_key: &str, + secret_key: &str, ) { let code = fs::read_to_string(source_file).unwrap_or_else(|e| { eprintln!("{}Error reading file: {}{}", RED, e, RESET); @@ -265,7 +305,7 @@ fn cmd_execute( json.push('}'); - let result = api_request("/execute", "POST", Some(&json), api_key); + let result = api_request("/execute", "POST", Some(&json), public_key, secret_key); // Print output let stdout_str = extract_json_string(&result, "stdout"); @@ -295,16 +335,17 @@ fn cmd_session( vcpu: Option, tmux: bool, screen: bool, - api_key: &str, + public_key: &str, + secret_key: &str, ) { if list { - let result = api_request("/sessions", "GET", None, api_key); + let result = api_request("/sessions", "GET", None, public_key, secret_key); println!("{}", result); return; } if let Some(id) = kill { - api_request(&format!("/sessions/{}", id), "DELETE", None, api_key); + api_request(&format!("/sessions/{}", id), "DELETE", None, public_key, secret_key); println!("{}Session terminated: {}{}", GREEN, id, RESET); return; } @@ -330,7 +371,7 @@ fn cmd_session( json.push('}'); println!("{}Creating session...{}", YELLOW, RESET); - let result = api_request("/sessions", "POST", Some(&json), api_key); + let result = api_request("/sessions", "POST", Some(&json), public_key, secret_key); let id = extract_json_string(&result, "id"); println!("{}Session created: {}{}", GREEN, id, RESET); } @@ -354,46 +395,47 @@ fn cmd_service( dump_file: Option<&str>, network: Option<&str>, vcpu: Option, - api_key: &str, + public_key: &str, + secret_key: &str, ) { if list { - let result = api_request("/services", "GET", None, api_key); + let result = api_request("/services", "GET", None, public_key, secret_key); println!("{}", result); return; } if let Some(id) = info { - let result = api_request(&format!("/services/{}", id), "GET", None, api_key); + let result = api_request(&format!("/services/{}", id), "GET", None, public_key, secret_key); println!("{}", result); return; } if let Some(id) = logs { - let result = api_request(&format!("/services/{}/logs", id), "GET", None, api_key); + let result = api_request(&format!("/services/{}/logs", id), "GET", None, public_key, secret_key); println!("{}", extract_json_string(&result, "logs")); return; } if let Some(id) = tail { - let result = api_request(&format!("/services/{}/logs?lines=9000", id), "GET", None, api_key); + let result = api_request(&format!("/services/{}/logs?lines=9000", id), "GET", None, public_key, secret_key); println!("{}", extract_json_string(&result, "logs")); return; } if let Some(id) = sleep { - api_request(&format!("/services/{}/sleep", id), "POST", None, api_key); + api_request(&format!("/services/{}/sleep", id), "POST", None, public_key, secret_key); println!("{}Service sleeping: {}{}", GREEN, id, RESET); return; } if let Some(id) = wake { - api_request(&format!("/services/{}/wake", id), "POST", None, api_key); + api_request(&format!("/services/{}/wake", id), "POST", None, public_key, secret_key); println!("{}Service waking: {}{}", GREEN, id, RESET); return; } if let Some(id) = destroy { - api_request(&format!("/services/{}", id), "DELETE", None, api_key); + api_request(&format!("/services/{}", id), "DELETE", None, public_key, secret_key); println!("{}Service destroyed: {}{}", GREEN, id, RESET); return; } @@ -401,7 +443,7 @@ fn cmd_service( if let Some(id) = execute { let cmd = command.unwrap_or(""); let json = format!(r#"{{"command":"{}"}}"#, escape_json(cmd)); - let result = api_request(&format!("/services/{}/execute", id), "POST", Some(&json), api_key); + let result = api_request(&format!("/services/{}/execute", id), "POST", Some(&json), public_key, secret_key); let stdout_str = extract_json_string(&result, "stdout"); let stderr_str = extract_json_string(&result, "stderr"); if !stdout_str.is_empty() { @@ -416,7 +458,7 @@ fn cmd_service( if let Some(id) = dump_bootstrap { eprintln!("Fetching bootstrap script from {}...", id); let json = r#"{"command":"cat /tmp/bootstrap.sh"}"#; - let result = api_request(&format!("/services/{}/execute", id), "POST", Some(json), api_key); + let result = api_request(&format!("/services/{}/execute", id), "POST", Some(json), public_key, secret_key); let bootstrap = extract_json_string(&result, "stdout"); if !bootstrap.is_empty() { @@ -495,7 +537,7 @@ fn cmd_service( json.push('}'); - let result = api_request("/services", "POST", Some(&json), api_key); + let result = api_request("/services", "POST", Some(&json), public_key, secret_key); let id = extract_json_string(&result, "id"); println!("{}Service created: {}{}", GREEN, id, RESET); return; @@ -505,8 +547,8 @@ fn cmd_service( process::exit(1); } -fn cmd_key(extend: bool, api_key: &str) { - let result = api_request("/keys/validate", "POST", Some("{}"), api_key); +fn cmd_key(extend: bool, public_key: &str, secret_key: &str) { + let result = api_request("/keys/validate", "POST", Some("{}"), public_key, secret_key); let status = extract_json_string(&result, "status"); let public_key = extract_json_string(&result, "public_key"); @@ -647,7 +689,7 @@ fn main() { } } "session" => { - let key = get_api_key(api_key.as_deref()); + let (public_key, secret_key) = get_api_keys(api_key.as_deref()); cmd_session( args.contains(&"--list".to_string()), args.iter().position(|x| x == "--kill").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), @@ -656,12 +698,13 @@ fn main() { vcpu, args.contains(&"--tmux".to_string()), args.contains(&"--screen".to_string()), - &key, + &public_key, + &secret_key, ); return; } "service" => { - let key = get_api_key(api_key.as_deref()); + let (public_key, secret_key) = get_api_keys(api_key.as_deref()); cmd_service( args.iter().position(|x| x == "--name").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), args.iter().position(|x| x == "--ports").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), @@ -681,15 +724,17 @@ fn main() { args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), network.as_deref(), vcpu, - &key, + &public_key, + &secret_key, ); return; } "key" => { - let key = get_api_key(api_key.as_deref()); + let (public_key, secret_key) = get_api_keys(api_key.as_deref()); cmd_key( args.contains(&"--extend".to_string()), - &key, + &public_key, + &secret_key, ); return; } @@ -704,7 +749,7 @@ fn main() { // Execute mode if let Some(file) = source_file { - let key = get_api_key(api_key.as_deref()); + let (public_key, secret_key) = get_api_keys(api_key.as_deref()); cmd_execute( &file, envs, @@ -713,7 +758,8 @@ fn main() { output_dir.as_deref(), network.as_deref(), vcpu, - &key, + &public_key, + &secret_key, ); } else { eprintln!("{}Error: No source file specified{}", RED, RESET); diff --git a/un.scm b/un.scm index 170966c..cf0f3f0 100644 --- a/un.scm +++ b/un.scm @@ -101,8 +101,14 @@ (define (curl-post api-key endpoint json-data) (let* ((tmp-file (write-temp-file json-data)) - (cmd (format #f "curl -s -X POST https://api.unsandbox.com~a -H 'Content-Type: application/json' -H 'Authorization: Bearer ~a' -d @~a" - endpoint api-key tmp-file)) + (keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (cmd (string-append "curl -s -X POST https://api.unsandbox.com" endpoint + " -H 'Content-Type: application/json' " + (string-join auth-headers " ") + " -d @" tmp-file)) (port (open-input-pipe cmd)) (output (let loop ((lines '())) (let ((line (read-line port))) @@ -114,8 +120,12 @@ output)) (define (curl-get api-key endpoint) - (let* ((cmd (format #f "curl -s https://api.unsandbox.com~a -H 'Authorization: Bearer ~a'" - endpoint api-key)) + (let* ((keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "GET" endpoint "")) + (cmd (string-append "curl -s https://api.unsandbox.com" endpoint + " " (string-join auth-headers " "))) (port (open-input-pipe cmd)) (output (let loop ((lines '())) (let ((line (read-line port))) @@ -126,8 +136,12 @@ output)) (define (curl-delete api-key endpoint) - (let* ((cmd (format #f "curl -s -X DELETE https://api.unsandbox.com~a -H 'Authorization: Bearer ~a'" - endpoint api-key)) + (let* ((keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "DELETE" endpoint "")) + (cmd (string-append "curl -s -X DELETE https://api.unsandbox.com" endpoint + " " (string-join auth-headers " "))) (port (open-input-pipe cmd)) (output (let loop ((lines '())) (let ((line (read-line port))) @@ -139,8 +153,14 @@ (define (curl-post-portal api-key endpoint json-data) (let* ((tmp-file (write-temp-file json-data)) - (cmd (format #f "curl -s -X POST ~a~a -H 'Content-Type: application/json' -H 'Authorization: Bearer ~a' -d @~a" - portal-base endpoint api-key tmp-file)) + (keys (get-api-keys)) + (public-key (car keys)) + (secret-key (cadr keys)) + (auth-headers (build-auth-headers public-key secret-key "POST" endpoint json-data)) + (cmd (string-append "curl -s -X POST " portal-base endpoint + " -H 'Content-Type: application/json' " + (string-join auth-headers " ") + " -d @" tmp-file)) (port (open-input-pipe cmd)) (output (let loop ((lines '())) (let ((line (read-line port))) @@ -151,11 +171,42 @@ (delete-file tmp-file) output)) +(define (get-api-keys) + (let ((public-key (getenv "UNSANDBOX_PUBLIC_KEY")) + (secret-key (getenv "UNSANDBOX_SECRET_KEY")) + (api-key (getenv "UNSANDBOX_API_KEY"))) + (cond + ((and public-key secret-key) (list public-key secret-key)) + (api-key (list api-key #f)) + (else (begin + (display "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)\n" (current-error-port)) + (exit 1)))))) + (define (get-api-key) - (or (getenv "UNSANDBOX_API_KEY") - (begin - (display "Error: UNSANDBOX_API_KEY not set\n" (current-error-port)) - (exit 1)))) + (car (get-api-keys))) + +(define (hmac-sha256 secret message) + "Compute HMAC-SHA256 using openssl command" + (let* ((cmd (format #f "echo -n '~a' | openssl dgst -sha256 -hmac '~a' | awk '{print $2}'" + (string-append (list->string (map (lambda (c) (if (char=? c #\') #\space c)) (string->list message)))) + (string-append (list->string (map (lambda (c) (if (char=? c #\') #\space c)) (string->list secret)))))) + (port (open-input-pipe cmd)) + (result (read-line port))) + (close-pipe port) + (string-trim-both result))) + +(define (make-signature secret-key timestamp method path body) + (let ((message (format #f "~a:~a:~a:~a" timestamp method path body))) + (hmac-sha256 secret-key message))) + +(define (build-auth-headers public-key secret-key method path body) + (if secret-key + (let* ((timestamp (number->string (quotient (current-time) 1))) + (signature (make-signature secret-key timestamp method path body))) + (list "-H" (format #f "Authorization: Bearer ~a" public-key) + "-H" (format #f "X-Timestamp: ~a" timestamp) + "-H" (format #f "X-Signature: ~a" signature))) + (list "-H" (format #f "Authorization: Bearer ~a" public-key)))) (define (json-extract-string json key) "Extract string value for key from JSON (simple parser)" diff --git a/un.sh b/un.sh old mode 100644 new mode 100755 index 35e6c3f..2b25b60 --- a/un.sh +++ b/un.sh @@ -130,27 +130,60 @@ api_request() { local endpoint="$1" local method="${2:-GET}" local data="${3:-}" - local api_key="${4:-${UNSANDBOX_API_KEY}}" + local public_key="${4:-${UNSANDBOX_PUBLIC_KEY:-}}" + local secret_key="${5:-${UNSANDBOX_SECRET_KEY:-}}" - if [[ -z "$api_key" ]]; then - echo -e "${RED}Error: UNSANDBOX_API_KEY not set${RESET}" >&2 + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if [[ -z "$public_key" ]] && [[ -n "${UNSANDBOX_API_KEY:-}" ]]; then + public_key="${UNSANDBOX_API_KEY}" + secret_key="" + fi + + if [[ -z "$public_key" ]]; then + echo -e "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}" >&2 exit 1 fi local url="${API_BASE}${endpoint}" - local tmpfile=$(mktemp) + local timestamp=$(date +%s) + local body="${data:-}" + + # Build HMAC signature: timestamp:METHOD:path:body + local sig_input="${timestamp}:${method}:${endpoint}:${body}" + local signature="" + + if [[ -n "$secret_key" ]]; then + signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //') + fi if [[ -n "$data" ]]; then local response - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $api_key" \ - -H "Content-Type: application/json" \ - -d "$data" 2>&1) + if [[ -n "$signature" ]]; then + response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ + -H "Authorization: Bearer $public_key" \ + -H "X-Timestamp: $timestamp" \ + -H "X-Signature: $signature" \ + -H "Content-Type: application/json" \ + -d "$data" 2>&1) + else + response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ + -H "Authorization: Bearer $public_key" \ + -H "Content-Type: application/json" \ + -d "$data" 2>&1) + fi else local response - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $api_key" \ - -H "Content-Type: application/json" 2>&1) + if [[ -n "$signature" ]]; then + response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ + -H "Authorization: Bearer $public_key" \ + -H "X-Timestamp: $timestamp" \ + -H "X-Signature: $signature" \ + -H "Content-Type: application/json" 2>&1) + else + response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ + -H "Authorization: Bearer $public_key" \ + -H "Content-Type: application/json" 2>&1) + fi fi local http_code=$(echo "$response" | tail -n1) @@ -172,7 +205,7 @@ cmd_execute() { local output_dir="." local network="" local vcpu="" - local api_key="${UNSANDBOX_API_KEY}" + local api_key="${UNSANDBOX_API_KEY:-}" # Parse arguments while [[ $# -gt 0 ]]; do @@ -300,7 +333,7 @@ cmd_session() { local screen=false local network="" local vcpu="" - local api_key="${UNSANDBOX_API_KEY}" + local api_key="${UNSANDBOX_API_KEY:-}" while [[ $# -gt 0 ]]; do case "$1" in @@ -409,7 +442,7 @@ cmd_service() { local command="" local network="" local vcpu="" - local api_key="${UNSANDBOX_API_KEY}" + local api_key="${UNSANDBOX_API_KEY:-}" while [[ $# -gt 0 ]]; do case "$1" in @@ -743,7 +776,7 @@ validate_key() { } cmd_key() { - local api_key="${UNSANDBOX_API_KEY}" + local api_key="${UNSANDBOX_API_KEY:-}" local extend=false # Parse arguments diff --git a/un.tcl b/un.tcl old mode 100644 new mode 100755 index ad61533..df10833 --- a/un.tcl +++ b/un.tcl @@ -1,3 +1,4 @@ +#!/usr/bin/env tclsh # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # # This is free public domain software for the public good of a permacomputer hosted @@ -34,8 +35,6 @@ # https://www.foxhop.net # https://www.unturf.com/software -#!/usr/bin/env tclsh - # unsandbox CLI - TCL implementation # Full-featured CLI matching un.c/un.py capabilities @@ -43,6 +42,7 @@ package require http package require json package require tls package require base64 +package require sha256 # Register https support ::http::register https 443 ::tls::socket @@ -72,12 +72,28 @@ array set EXT_MAP { .tcl tcl .raku raku .m objc } -proc get_api_key {} { - if {[info exists ::env(UNSANDBOX_API_KEY)]} { - return $::env(UNSANDBOX_API_KEY) +proc get_api_keys {} { + set public_key "" + set secret_key "" + + if {[info exists ::env(UNSANDBOX_PUBLIC_KEY)]} { + set public_key $::env(UNSANDBOX_PUBLIC_KEY) } - puts stderr "${::RED}Error: UNSANDBOX_API_KEY not set${::RESET}" - exit 1 + if {[info exists ::env(UNSANDBOX_SECRET_KEY)]} { + set secret_key $::env(UNSANDBOX_SECRET_KEY) + } + + # Fallback to old UNSANDBOX_API_KEY for backwards compat + if {$public_key eq "" && [info exists ::env(UNSANDBOX_API_KEY)]} { + set public_key $::env(UNSANDBOX_API_KEY) + set secret_key "" + } + + if {$public_key eq ""} { + puts stderr "${::RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${::RESET}" + exit 1 + } + return [list $public_key $secret_key] } proc detect_language {filename} { @@ -103,16 +119,29 @@ proc detect_language {filename} { exit 1 } -proc api_request {endpoint method data api_key} { +proc api_request {endpoint method data public_key secret_key} { set url "${::API_BASE}${endpoint}" - set headers [list Authorization "Bearer $api_key" Content-Type "application/json"] + set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] + + set json_data "" + if {$method ne "GET" && $method ne "DELETE" && [llength $data] > 0} { + set json_data [::json::write object {*}$data] + } + + # Add HMAC signature if secret_key is present + if {$secret_key ne ""} { + set timestamp [clock seconds] + set sig_input "${timestamp}:${method}:${endpoint}:${json_data}" + set signature [::sha2::hmac -hex -key $secret_key $sig_input] + lappend headers X-Timestamp $timestamp + lappend headers X-Signature $signature + } if {$method eq "GET"} { set token [::http::geturl $url -headers $headers -timeout 300000] } elseif {$method eq "DELETE"} { set token [::http::geturl $url -method DELETE -headers $headers -timeout 300000] } else { - set json_data [::json::write object {*}$data] set token [::http::geturl $url -method $method -headers $headers -query $json_data -timeout 300000] } @@ -131,7 +160,7 @@ proc api_request {endpoint method data api_key} { } proc cmd_execute {args} { - set api_key [get_api_key] + lassign [get_api_keys] public_key secret_key set source_file "" set env_vars [dict create] set input_files [list] @@ -236,7 +265,7 @@ proc cmd_execute {args} { } # Execute - set result [api_request "/execute" "POST" $payload $api_key] + set result [api_request "/execute" "POST" $payload $public_key $secret_key] # Print output if {[dict exists $result stdout]} { @@ -275,7 +304,7 @@ proc cmd_execute {args} { } proc cmd_session {args} { - set api_key [get_api_key] + lassign [get_api_keys] public_key secret_key set list_mode 0 set kill_id "" set shell "" @@ -309,7 +338,7 @@ proc cmd_session {args} { } if {$list_mode} { - set result [api_request "/sessions" "GET" {} $api_key] + set result [api_request "/sessions" "GET" {} $public_key $secret_key] set sessions [dict get $result sessions] if {[llength $sessions] == 0} { puts "No active sessions" @@ -327,7 +356,7 @@ proc cmd_session {args} { } if {$kill_id ne ""} { - api_request "/sessions/$kill_id" "DELETE" {} $api_key + api_request "/sessions/$kill_id" "DELETE" {} $public_key $secret_key puts "${::GREEN}Session terminated: $kill_id${::RESET}" return } @@ -347,13 +376,13 @@ proc cmd_session {args} { } puts "${::YELLOW}Creating session...${::RESET}" - set result [api_request "/sessions" "POST" $payload $api_key] + set result [api_request "/sessions" "POST" $payload $public_key $secret_key] puts "${::GREEN}Session created: [dict get $result id]${::RESET}" puts "${::YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${::RESET}" } proc cmd_key {args} { - set api_key [get_api_key] + lassign [get_api_keys] public_key secret_key set extend_mode 0 # Parse arguments @@ -368,7 +397,16 @@ proc cmd_key {args} { # POST to /keys/validate with Bearer auth set url "${::PORTAL_BASE}/keys/validate" - set headers [list Authorization "Bearer $api_key" Content-Type "application/json"] + set headers [list Authorization "Bearer $public_key" Content-Type "application/json"] + + # Add HMAC signature if secret_key is present + if {$secret_key ne ""} { + set timestamp [clock seconds] + set sig_input "${timestamp}:POST:/keys/validate:" + set signature [::sha2::hmac -hex -key $secret_key $sig_input] + lappend headers X-Timestamp $timestamp + lappend headers X-Signature $signature + } set token [::http::geturl $url -method POST -headers $headers -timeout 30000] set status [::http::status $token] @@ -438,7 +476,7 @@ proc cmd_key {args} { } proc cmd_service {args} { - set api_key [get_api_key] + lassign [get_api_keys] public_key secret_key set list_mode 0 set info_id "" set logs_id "" @@ -517,7 +555,7 @@ proc cmd_service {args} { } if {$list_mode} { - set result [api_request "/services" "GET" {} $api_key] + set result [api_request "/services" "GET" {} $public_key $secret_key] set services [dict get $result services] if {[llength $services] == 0} { puts "No services" @@ -538,31 +576,31 @@ proc cmd_service {args} { } if {$info_id ne ""} { - set result [api_request "/services/$info_id" "GET" {} $api_key] + set result [api_request "/services/$info_id" "GET" {} $public_key $secret_key] puts [::json::write object {*}[dict_to_json_list $result]] return } if {$logs_id ne ""} { - set result [api_request "/services/$logs_id/logs" "GET" {} $api_key] + set result [api_request "/services/$logs_id/logs" "GET" {} $public_key $secret_key] puts [dict get $result logs] return } if {$sleep_id ne ""} { - api_request "/services/$sleep_id/sleep" "POST" {} $api_key + api_request "/services/$sleep_id/sleep" "POST" {} $public_key $secret_key puts "${::GREEN}Service sleeping: $sleep_id${::RESET}" return } if {$wake_id ne ""} { - api_request "/services/$wake_id/wake" "POST" {} $api_key + api_request "/services/$wake_id/wake" "POST" {} $public_key $secret_key puts "${::GREEN}Service waking: $wake_id${::RESET}" return } if {$destroy_id ne ""} { - api_request "/services/$destroy_id" "DELETE" {} $api_key + api_request "/services/$destroy_id" "DELETE" {} $public_key $secret_key puts "${::GREEN}Service destroyed: $destroy_id${::RESET}" return } @@ -570,7 +608,7 @@ proc cmd_service {args} { if {$dump_bootstrap_id ne ""} { puts stderr "Fetching bootstrap script from $dump_bootstrap_id..." set payload [list command [::json::write string "cat /tmp/bootstrap.sh"]] - set result [api_request "/services/$dump_bootstrap_id/execute" "POST" $payload $api_key] + set result [api_request "/services/$dump_bootstrap_id/execute" "POST" $payload $public_key $secret_key] if {[dict exists $result stdout] && [dict get $result stdout] ne ""} { set bootstrap [dict get $result stdout] @@ -628,7 +666,7 @@ proc cmd_service {args} { lappend payload vcpu $vcpu } - set result [api_request "/services" "POST" $payload $api_key] + set result [api_request "/services" "POST" $payload $public_key $secret_key] puts "${::GREEN}Service created: [dict get $result id]${::RESET}" puts "Name: [dict get $result name]" if {[dict exists $result url]} { diff --git a/un.ts b/un.ts index 3ce5163..7ec69da 100644 --- a/un.ts +++ b/un.ts @@ -54,6 +54,7 @@ import * as fs from 'fs'; import * as https from 'https'; import * as path from 'path'; +import * as crypto from 'crypto'; const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; @@ -112,13 +113,28 @@ interface Args { extend: boolean; } -function getApiKey(argsKey: string | null): string { - const key = argsKey || process.env.UNSANDBOX_API_KEY; - if (!key) { - console.error(`${RED}Error: UNSANDBOX_API_KEY not set${RESET}`); - process.exit(1); +interface ApiKeys { + publicKey: string; + secretKey: string; +} + +function getApiKeys(argsKey: string | null): ApiKeys { + let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; + let secretKey = process.env.UNSANDBOX_SECRET_KEY; + + if (!publicKey || !secretKey) { + const oldKey = argsKey || process.env.UNSANDBOX_API_KEY; + if (oldKey) { + publicKey = oldKey; + secretKey = oldKey; + } else { + console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); + console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); + process.exit(1); + } } - return key; + + return { publicKey, secretKey }; } function detectLanguage(filename: string): string { @@ -143,15 +159,22 @@ function detectLanguage(filename: string): string { return lang; } -function apiRequest(endpoint: string, method: string = "GET", data: any = null, apiKey: string): Promise { +function apiRequest(endpoint: string, method: string = "GET", data: any = null, keys: ApiKeys): Promise { return new Promise((resolve, reject) => { const url = new URL(API_BASE + endpoint); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const body = data ? JSON.stringify(data) : ''; + const message = `${timestamp}:${method}:${url.pathname}${url.search}:${body}`; + const signature = crypto.createHmac('sha256', keys.secretKey).update(message).digest('hex'); + const options: https.RequestOptions = { hostname: url.hostname, path: url.pathname + url.search, method: method, headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${keys.publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, 'Content-Type': 'application/json' }, timeout: 300000 @@ -180,21 +203,28 @@ function apiRequest(endpoint: string, method: string = "GET", data: any = null, }); if (data) { - req.write(JSON.stringify(data)); + req.write(body); } req.end(); }); } -function portalRequest(endpoint: string, method: string = "GET", data: any = null, apiKey: string): Promise { +function portalRequest(endpoint: string, method: string = "GET", data: any = null, keys: ApiKeys): Promise { return new Promise((resolve, reject) => { const url = new URL(PORTAL_BASE + endpoint); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const body = data ? JSON.stringify(data) : ''; + const message = `${timestamp}:${method}:${url.pathname}${url.search}:${body}`; + const signature = crypto.createHmac('sha256', keys.secretKey).update(message).digest('hex'); + const options: https.RequestOptions = { hostname: url.hostname, path: url.pathname + url.search, method: method, headers: { - 'Authorization': `Bearer ${apiKey}`, + 'Authorization': `Bearer ${keys.publicKey}`, + 'X-Timestamp': timestamp, + 'X-Signature': signature, 'Content-Type': 'application/json' }, timeout: 30000 @@ -218,14 +248,14 @@ function portalRequest(endpoint: string, method: string = "GET", data: any = nul }); if (data) { - req.write(JSON.stringify(data)); + req.write(body); } req.end(); }); } async function cmdExecute(args: Args): Promise { - const apiKey = getApiKey(args.apiKey); + const keys = getApiKeys(args.apiKey); let code: string; try { @@ -267,7 +297,7 @@ async function cmdExecute(args: Args): Promise { if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; - const result = await apiRequest("/execute", "POST", payload, apiKey); + const result = await apiRequest("/execute", "POST", payload, keys); if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); @@ -289,10 +319,10 @@ async function cmdExecute(args: Args): Promise { } async function cmdSession(args: Args): Promise { - const apiKey = getApiKey(args.apiKey); + const keys = getApiKeys(args.apiKey); if (args.list) { - const result = await apiRequest("/sessions", "GET", null, apiKey); + const result = await apiRequest("/sessions", "GET", null, keys); const sessions = result.sessions || []; if (sessions.length === 0) { console.log("No active sessions"); @@ -306,7 +336,7 @@ async function cmdSession(args: Args): Promise { } if (args.kill) { - await apiRequest(`/sessions/${args.kill}`, "DELETE", null, apiKey); + await apiRequest(`/sessions/${args.kill}`, "DELETE", null, keys); console.log(`${GREEN}Session terminated: ${args.kill}${RESET}`); return; } @@ -325,16 +355,16 @@ async function cmdSession(args: Args): Promise { if (args.audit) payload.audit = true; console.log(`${YELLOW}Creating session...${RESET}`); - const result = await apiRequest("/sessions", "POST", payload, apiKey); + const result = await apiRequest("/sessions", "POST", payload, keys); console.log(`${GREEN}Session created: ${result.id || 'N/A'}${RESET}`); console.log(`${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}`); } async function cmdService(args: Args): Promise { - const apiKey = getApiKey(args.apiKey); + const keys = getApiKeys(args.apiKey); if (args.list) { - const result = await apiRequest("/services", "GET", null, apiKey); + const result = await apiRequest("/services", "GET", null, keys); const services = result.services || []; if (services.length === 0) { console.log("No services"); @@ -350,44 +380,44 @@ async function cmdService(args: Args): Promise { } if (args.info) { - const result = await apiRequest(`/services/${args.info}`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.info}`, "GET", null, keys); console.log(JSON.stringify(result, null, 2)); return; } if (args.logs) { - const result = await apiRequest(`/services/${args.logs}/logs`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.logs}/logs`, "GET", null, keys); console.log(result.logs || ""); return; } if (args.tail) { - const result = await apiRequest(`/services/${args.tail}/logs?lines=9000`, "GET", null, apiKey); + const result = await apiRequest(`/services/${args.tail}/logs?lines=9000`, "GET", null, keys); console.log(result.logs || ""); return; } if (args.sleep) { - await apiRequest(`/services/${args.sleep}/sleep`, "POST", null, apiKey); + await apiRequest(`/services/${args.sleep}/sleep`, "POST", null, keys); console.log(`${GREEN}Service sleeping: ${args.sleep}${RESET}`); return; } if (args.wake) { - await apiRequest(`/services/${args.wake}/wake`, "POST", null, apiKey); + await apiRequest(`/services/${args.wake}/wake`, "POST", null, keys); console.log(`${GREEN}Service waking: ${args.wake}${RESET}`); return; } if (args.destroy) { - await apiRequest(`/services/${args.destroy}`, "DELETE", null, apiKey); + await apiRequest(`/services/${args.destroy}`, "DELETE", null, keys); console.log(`${GREEN}Service destroyed: ${args.destroy}${RESET}`); return; } if (args.execute) { const payload = { command: args.command_arg }; - const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, apiKey); + const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, keys); if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); return; @@ -396,7 +426,7 @@ async function cmdService(args: Args): Promise { if (args.dumpBootstrap) { console.error(`Fetching bootstrap script from ${args.dumpBootstrap}...`); const payload = { command: "cat /tmp/bootstrap.sh" }; - const result = await apiRequest(`/services/${args.dumpBootstrap}/execute`, "POST", payload, apiKey); + const result = await apiRequest(`/services/${args.dumpBootstrap}/execute`, "POST", payload, keys); if (result.stdout) { const bootstrap = result.stdout; @@ -436,7 +466,7 @@ async function cmdService(args: Args): Promise { if (args.network) payload.network = args.network; if (args.vcpu) payload.vcpu = args.vcpu; - const result = await apiRequest("/services", "POST", payload, apiKey); + const result = await apiRequest("/services", "POST", payload, keys); console.log(`${GREEN}Service created: ${result.id || 'N/A'}${RESET}`); console.log(`Name: ${result.name || 'N/A'}`); if (result.url) console.log(`URL: ${result.url}`); @@ -468,9 +498,9 @@ function openBrowser(url: string): void { }); } -async function validateKey(apiKey: string, shouldExtend: boolean): Promise { +async function validateKey(keys: ApiKeys, shouldExtend: boolean): Promise { try { - const result = await portalRequest("/keys/validate", "POST", {}, apiKey); + const result = await portalRequest("/keys/validate", "POST", {}, keys); // Handle --extend flag first if (shouldExtend) { @@ -513,8 +543,8 @@ async function validateKey(apiKey: string, shouldExtend: boolean): Promise } async function cmdKey(args: Args): Promise { - const apiKey = getApiKey(args.apiKey); - await validateKey(apiKey, args.extend); + const keys = getApiKeys(args.apiKey); + await validateKey(keys, args.extend); } function parseArgs(argv: string[]): Args { diff --git a/un.v b/un.v index 31bbb91..7185bb0 100644 --- a/un.v +++ b/un.v @@ -126,7 +126,10 @@ fn extract_json_string(json string, key string) string { } fn cmd_key(extend bool, api_key string) { - cmd := "curl -s -X POST '${portal_base}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '{}'" + pub_key := get_public_key() + secret_key := get_secret_key() + body := '{}' + cmd := "BODY='${body}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/keys/validate:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${portal_base}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" result := exec_curl(cmd) public_key := extract_json_string(result, 'public_key') @@ -229,19 +232,24 @@ fn cmd_execute(source_file string, envs []string, artifacts bool, network string } json += '}' - cmd := "curl -s -X POST '${api_base}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '${json}'" + pub_key := get_public_key() + secret_key := get_secret_key() + cmd := "BODY='${json}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/execute:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" println(exec_curl(cmd)) } fn cmd_session(list bool, kill string, shell string, network string, vcpu int, tmux bool, screen bool, api_key string) { + pub_key := get_public_key() + secret_key := get_secret_key() + if list { - cmd := "curl -s -X GET '${api_base}/sessions' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:GET:/sessions:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/sessions' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" println(exec_curl(cmd)) return } if kill != '' { - cmd := "curl -s -X DELETE '${api_base}/sessions/${kill}' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:DELETE:/sessions/${kill}:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/sessions/${kill}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" exec_curl(cmd) println('${green}Session terminated: ${kill}${reset}') return @@ -264,51 +272,54 @@ fn cmd_session(list bool, kill string, shell string, network string, vcpu int, t json += '}' println('${yellow}Creating session...${reset}') - cmd := "curl -s -X POST '${api_base}/sessions' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '${json}'" + cmd := "BODY='${json}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/sessions:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/sessions' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" println(exec_curl(cmd)) } fn cmd_service(name string, ports string, service_type string, bootstrap string, list bool, info string, logs string, tail string, sleep string, wake string, destroy string, execute string, command string, dump_bootstrap string, dump_file string, network string, vcpu int, api_key string) { + pub_key := get_public_key() + secret_key := get_secret_key() + if list { - cmd := "curl -s -X GET '${api_base}/services' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:GET:/services:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" println(exec_curl(cmd)) return } if info != '' { - cmd := "curl -s -X GET '${api_base}/services/${info}' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:GET:/services/${info}:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services/${info}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" println(exec_curl(cmd)) return } if logs != '' { - cmd := "curl -s -X GET '${api_base}/services/${logs}/logs' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:GET:/services/${logs}/logs:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services/${logs}/logs' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" print(exec_curl(cmd)) return } if tail != '' { - cmd := "curl -s -X GET '${api_base}/services/${tail}/logs?lines=9000' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:GET:/services/${tail}/logs:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/services/${tail}/logs?lines=9000' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" print(exec_curl(cmd)) return } if sleep != '' { - cmd := "curl -s -X POST '${api_base}/services/${sleep}/sleep' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/services/${sleep}/sleep:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${sleep}/sleep' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" exec_curl(cmd) println('${green}Service sleeping: ${sleep}${reset}') return } if wake != '' { - cmd := "curl -s -X POST '${api_base}/services/${wake}/wake' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/services/${wake}/wake:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${wake}/wake' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" exec_curl(cmd) println('${green}Service waking: ${wake}${reset}') return } if destroy != '' { - cmd := "curl -s -X DELETE '${api_base}/services/${destroy}' -H 'Authorization: Bearer ${api_key}'" + cmd := "TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:DELETE:/services/${destroy}:\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X DELETE '${api_base}/services/${destroy}' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\"" exec_curl(cmd) println('${green}Service destroyed: ${destroy}${reset}') return @@ -316,7 +327,7 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, if execute != '' { json := '{"command":"${escape_json(command)}"}' - cmd := "curl -s -X POST '${api_base}/services/${execute}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '${json}'" + cmd := "BODY='${json}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/services/${execute}/execute:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${execute}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" result := exec_curl(cmd) stdout_str := extract_json_string(result, 'stdout') @@ -332,7 +343,8 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, if dump_bootstrap != '' { eprintln('Fetching bootstrap script from ${dump_bootstrap}...') - cmd := "curl -s -X POST '${api_base}/services/${dump_bootstrap}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '{\"command\":\"cat /tmp/bootstrap.sh\"}'" + json := '{"command":"cat /tmp/bootstrap.sh"}' + cmd := "BODY='${json}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/services/${dump_bootstrap}/execute:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services/${dump_bootstrap}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" result := exec_curl(cmd) bootstrap_script := extract_json_string(result, 'stdout') @@ -379,7 +391,7 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, json += '}' println('${yellow}Creating service...${reset}') - cmd := "curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${api_key}' -d '${json}'" + cmd := "BODY='${json}'; TIMESTAMP=\\$(date +%s); MESSAGE=\"\\$TIMESTAMP:POST:/services:\\$BODY\"; SIGNATURE=\\$(echo -n \"\\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X POST '${api_base}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \\$TIMESTAMP\" -H \"X-Signature: \\$SIGNATURE\" -d \"\\$BODY\"" println(exec_curl(cmd)) return } @@ -388,8 +400,33 @@ fn cmd_service(name string, ports string, service_type string, bootstrap string, exit(1) } +fn get_public_key() string { + pub_key := os.getenv('UNSANDBOX_PUBLIC_KEY') + if pub_key != '' { + return pub_key + } + api_key := os.getenv('UNSANDBOX_API_KEY') + if api_key != '' { + return api_key + } + eprintln('${red}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY environment variable not set${reset}') + exit(1) +} + +fn get_secret_key() string { + sec_key := os.getenv('UNSANDBOX_SECRET_KEY') + if sec_key != '' { + return sec_key + } + api_key := os.getenv('UNSANDBOX_API_KEY') + if api_key != '' { + return api_key + } + return '' +} + fn main() { - mut api_key := os.getenv('UNSANDBOX_API_KEY') + mut api_key := get_public_key() if os.args.len < 2 { eprintln('Usage: ${os.args[0]} [options] ') diff --git a/un.zig b/un.zig index f54b3b4..56eafa9 100644 --- a/un.zig +++ b/un.zig @@ -50,10 +50,57 @@ const std = @import("std"); const fs = std.fs; const process = std.process; const mem = std.mem; +const time = std.time; const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; +fn computeHmacCmd(allocator: std.mem.Allocator, secret_key: []const u8, message: []const u8) ![]const u8 { + return try std.fmt.allocPrint(allocator, "echo -n '{s}' | openssl dgst -sha256 -hmac '{s}' -hex 2>/dev/null | sed 's/.*= //'", .{ message, secret_key }); +} + +fn getTimestamp(allocator: std.mem.Allocator) ![]const u8 { + const timestamp = std.time.timestamp(); + return try std.fmt.allocPrint(allocator, "{d}", .{timestamp}); +} + +fn buildAuthCmd(allocator: std.mem.Allocator, method: []const u8, path: []const u8, body: []const u8, public_key: []const u8, secret_key: []const u8) ![]const u8 { + if (secret_key.len == 0) { + // Legacy mode: use public_key as bearer token + return try std.fmt.allocPrint(allocator, "-H 'Authorization: Bearer {s}'", .{public_key}); + } + + // HMAC mode + const timestamp_str = try getTimestamp(allocator); + defer allocator.free(timestamp_str); + + const message = try std.fmt.allocPrint(allocator, "{s}:{s}:{s}:{s}", .{ timestamp_str, method, path, body }); + defer allocator.free(message); + + const hmac_cmd = try computeHmacCmd(allocator, secret_key, message); + defer allocator.free(hmac_cmd); + + // Execute HMAC command to get signature + var signature_buf: [256]u8 = undefined; + var fbs = std.io.fixedBufferStream(&signature_buf); + const signature_len = blk: { + const result = try std.process.Child.run(.{ + .allocator = allocator, + .argv = &[_][]const u8{ "sh", "-c", hmac_cmd }, + }); + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + const trimmed = mem.trim(u8, result.stdout, &std.ascii.whitespace); + @memcpy(signature_buf[0..trimmed.len], trimmed); + break :blk trimmed.len; + }; + + const signature = signature_buf[0..signature_len]; + + return try std.fmt.allocPrint(allocator, "-H 'Authorization: Bearer {s}' -H 'X-Timestamp: {s}' -H 'X-Signature: {s}'", .{ public_key, timestamp_str, signature }); +} + pub fn main() !u8 { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -70,10 +117,16 @@ pub fn main() !u8 { return 1; } - const api_key = std.process.getEnvVarOwned(allocator, "UNSANDBOX_API_KEY") catch blk: { + var public_key = std.process.getEnvVarOwned(allocator, "UNSANDBOX_PUBLIC_KEY") catch blk: { + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + break :blk std.process.getEnvVarOwned(allocator, "UNSANDBOX_API_KEY") catch try allocator.dupe(u8, ""); + }; + defer allocator.free(public_key); + + const secret_key = std.process.getEnvVarOwned(allocator, "UNSANDBOX_SECRET_KEY") catch blk: { break :blk try allocator.dupe(u8, ""); }; - defer allocator.free(api_key); + defer allocator.free(secret_key); // Handle session command if (mem.eql(u8, args[1], "session")) { @@ -90,22 +143,36 @@ pub fn main() !u8 { } else if (mem.eql(u8, args[i], "--shell") and i + 1 < args.len) { i += 1; shell = args[i]; + } else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) { + i += 1; + allocator.free(public_key); + public_key = try allocator.dupe(u8, args[i]); } } if (list) { - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/sessions' -H 'Authorization: Bearer {s}'", .{ API_BASE, api_key }); + const auth_headers = try buildAuthCmd(allocator, "GET", "/sessions", "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/sessions' {s}", .{ API_BASE, auth_headers }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); std.debug.print("\n", .{}); } else if (kill) |k| { - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X DELETE '{s}/sessions/{s}' -H 'Authorization: Bearer {s}'", .{ API_BASE, k, api_key }); + const path = try std.fmt.allocPrint(allocator, "/sessions/{s}", .{k}); + defer allocator.free(path); + const auth_headers = try buildAuthCmd(allocator, "DELETE", path, "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X DELETE '{s}/sessions/{s}' {s}", .{ API_BASE, k, auth_headers }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); std.debug.print("\x1b[32mSession terminated: {s}\x1b[0m\n", .{k}); } else { const sh = shell orelse "bash"; - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/sessions' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -d '{{\"shell\":\"{s}\"}}'", .{ API_BASE, api_key, sh }); + const json = try std.fmt.allocPrint(allocator, "{{\"shell\":\"{s}\"}}", .{sh}); + defer allocator.free(json); + const auth_headers = try buildAuthCmd(allocator, "POST", "/sessions", json, public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/sessions' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json }); defer allocator.free(cmd); std.debug.print("\x1b[33mCreating session...\x1b[0m\n", .{}); _ = std.c.system(cmd.ptr); @@ -153,29 +220,50 @@ pub fn main() !u8 { } else if (mem.eql(u8, args[i], "--dump-file") and i + 1 < args.len) { i += 1; dump_file = args[i]; + } else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) { + i += 1; + allocator.free(public_key); + public_key = try allocator.dupe(u8, args[i]); } } if (list) { - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/services' -H 'Authorization: Bearer {s}'", .{ API_BASE, api_key }); + const auth_headers = try buildAuthCmd(allocator, "GET", "/services", "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/services' {s}", .{ API_BASE, auth_headers }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); std.debug.print("\n", .{}); } else if (info) |inf| { - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/services/{s}' -H 'Authorization: Bearer {s}'", .{ API_BASE, inf, api_key }); + const path = try std.fmt.allocPrint(allocator, "/services/{s}", .{inf}); + defer allocator.free(path); + const auth_headers = try buildAuthCmd(allocator, "GET", path, "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/services/{s}' {s}", .{ API_BASE, inf, auth_headers }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); std.debug.print("\n", .{}); } else if (execute) |exec_id| { const cmd_text = command orelse ""; - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services/{s}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -d '{{\"command\":\"{s}\"}}'", .{ API_BASE, exec_id, api_key, cmd_text }); + const json = try std.fmt.allocPrint(allocator, "{{\"command\":\"{s}\"}}", .{cmd_text}); + defer allocator.free(json); + const path = try std.fmt.allocPrint(allocator, "/services/{s}/execute", .{exec_id}); + defer allocator.free(path); + const auth_headers = try buildAuthCmd(allocator, "POST", path, json, public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services/{s}/execute' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, exec_id, auth_headers, json }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); std.debug.print("\n", .{}); } else if (dump_bootstrap) |bootstrap_id| { std.debug.print("Fetching bootstrap script from {s}...\n", .{bootstrap_id}); const tmp_file = "/tmp/unsandbox_bootstrap_dump.txt"; - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services/{s}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -d '{{\"command\":\"cat /tmp/bootstrap.sh\"}}' -o {s}", .{ API_BASE, bootstrap_id, api_key, tmp_file }); + const json = "{{\"command\":\"cat /tmp/bootstrap.sh\"}}"; + const path = try std.fmt.allocPrint(allocator, "/services/{s}/execute", .{bootstrap_id}); + defer allocator.free(path); + const auth_headers = try buildAuthCmd(allocator, "POST", path, json, public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services/{s}/execute' -H 'Content-Type: application/json' {s} -d '{s}' -o {s}", .{ API_BASE, bootstrap_id, auth_headers, json, tmp_file }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); @@ -231,7 +319,9 @@ pub fn main() !u8 { try writer.writeAll("}"); const json_str = json_stream.getWritten(); - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -d '{s}'", .{ API_BASE, api_key, json_str }); + const auth_headers = try buildAuthCmd(allocator, "POST", "/services", json_str, public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/services' -H 'Content-Type: application/json' {s} -d '{s}'", .{ API_BASE, auth_headers, json_str }); defer allocator.free(cmd); std.debug.print("\x1b[33mCreating service...\x1b[0m\n", .{}); _ = std.c.system(cmd.ptr); @@ -247,13 +337,19 @@ pub fn main() !u8 { while (i < args.len) : (i += 1) { if (mem.eql(u8, args[i], "--extend")) { extend = true; + } else if (mem.eql(u8, args[i], "-k") and i + 1 < args.len) { + i += 1; + allocator.free(public_key); + public_key = try allocator.dupe(u8, args[i]); } } if (extend) { // First validate to get the public_key const json_file = "/tmp/unsandbox_key_validate.json"; - const cmd_validate = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -o {s}", .{ PORTAL_BASE, api_key, json_file }); + const auth_headers = try buildAuthCmd(allocator, "POST", "/keys/validate", "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd_validate = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' {s} -o {s}", .{ PORTAL_BASE, auth_headers, json_file }); defer allocator.free(cmd_validate); _ = std.c.system(cmd_validate.ptr); @@ -268,15 +364,15 @@ pub fn main() !u8 { // Simple JSON parsing to find public_key (looking for "public_key":"value") const pk_prefix = "\"public_key\":\""; - var public_key: ?[]const u8 = null; + var public_key_value: ?[]const u8 = null; if (mem.indexOf(u8, json_content, pk_prefix)) |start_idx| { const value_start = start_idx + pk_prefix.len; if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { - public_key = json_content[value_start..end_idx]; + public_key_value = json_content[value_start..end_idx]; } } - if (public_key) |pk| { + if (public_key_value) |pk| { const url = try std.fmt.allocPrint(allocator, "{s}/keys/extend?pk={s}", .{ PORTAL_BASE, pk }); defer allocator.free(url); std.debug.print("\x1b[33mOpening browser to extend key...\x1b[0m\n", .{}); @@ -290,7 +386,9 @@ pub fn main() !u8 { } else { // Regular validation const json_file = "/tmp/unsandbox_key_validate.json"; - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -o {s}", .{ PORTAL_BASE, api_key, json_file }); + const auth_headers = try buildAuthCmd(allocator, "POST", "/keys/validate", "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/keys/validate' -H 'Content-Type: application/json' {s} -o {s}", .{ PORTAL_BASE, auth_headers, json_file }); defer allocator.free(cmd); _ = std.c.system(cmd.ptr); @@ -319,7 +417,7 @@ pub fn main() !u8 { } // Extract other fields - var public_key: ?[]const u8 = null; + var pub_key: ?[]const u8 = null; var tier: ?[]const u8 = null; var expires_at: ?[]const u8 = null; @@ -327,7 +425,7 @@ pub fn main() !u8 { if (mem.indexOf(u8, json_content, pk_prefix)) |start_idx| { const value_start = start_idx + pk_prefix.len; if (mem.indexOfPos(u8, json_content, value_start, "\"")) |end_idx| { - public_key = json_content[value_start..end_idx]; + pub_key = json_content[value_start..end_idx]; } } @@ -351,12 +449,12 @@ pub fn main() !u8 { if (status) |s| { if (mem.eql(u8, s, "valid")) { std.debug.print("\x1b[32mValid\x1b[0m\n", .{}); - if (public_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); + if (pub_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); if (tier) |t| std.debug.print("Tier: {s}\n", .{t}); if (expires_at) |exp| std.debug.print("Expires: {s}\n", .{exp}); } else if (mem.eql(u8, s, "expired")) { std.debug.print("\x1b[31mExpired\x1b[0m\n", .{}); - if (public_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); + if (pub_key) |pk| std.debug.print("Public Key: {s}\n", .{pk}); if (tier) |t| std.debug.print("Tier: {s}\n", .{t}); if (expires_at) |exp| std.debug.print("Expired: {s}\n", .{exp}); std.debug.print("\x1b[33mTo renew: Visit {s}/keys/extend\x1b[0m\n", .{PORTAL_BASE}); @@ -430,8 +528,14 @@ pub fn main() !u8 { } try writer.writeAll("\"}"); + // Read back the JSON to compute HMAC + const json_content = try fs.cwd().readFileAlloc(allocator, json_file, 10 * 1024 * 1024); + defer allocator.free(json_content); + // Execute with curl - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/execute' -H 'Content-Type: application/json' -H 'Authorization: Bearer {s}' -d @{s}", .{ API_BASE, api_key, json_file }); + const auth_headers = try buildAuthCmd(allocator, "POST", "/execute", json_content, public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X POST '{s}/execute' -H 'Content-Type: application/json' {s} -d @{s}", .{ API_BASE, auth_headers, json_file }); defer allocator.free(cmd); const result = std.c.system(cmd.ptr); diff --git a/un_deno.ts b/un_deno.ts index 1003533..19c9edb 100644 --- a/un_deno.ts +++ b/un_deno.ts @@ -63,13 +63,28 @@ const EXT_MAP: Record = { m: "objc", }; -function getApiKey(): string { - const key = Deno.env.get("UNSANDBOX_API_KEY"); - if (!key) { - console.error(`${RED}Error: UNSANDBOX_API_KEY not set${RESET}`); - Deno.exit(1); +interface ApiKeys { + publicKey: string; + secretKey: string; +} + +function getApiKeys(): ApiKeys { + let publicKey = Deno.env.get("UNSANDBOX_PUBLIC_KEY"); + let secretKey = Deno.env.get("UNSANDBOX_SECRET_KEY"); + + if (!publicKey || !secretKey) { + const oldKey = Deno.env.get("UNSANDBOX_API_KEY"); + if (oldKey) { + publicKey = oldKey; + secretKey = oldKey; + } else { + console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); + console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); + Deno.exit(1); + } } - return key; + + return { publicKey, secretKey }; } function detectLanguage(filename: string): string { @@ -92,13 +107,40 @@ async function apiRequest( endpoint: string, method: string, data?: unknown, - apiKey?: string, + keys?: ApiKeys, baseUrl?: string, ): Promise { const base = baseUrl || API_BASE; const url = `${base}${endpoint}`; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const body = data ? JSON.stringify(data) : ''; + + // Parse URL to get pathname and search + const urlObj = new URL(url); + const message = `${timestamp}:${method}:${urlObj.pathname}${urlObj.search}:${body}`; + + // Create HMAC signature using Web Crypto API + const encoder = new TextEncoder(); + const keyData = encoder.encode(keys!.secretKey); + const messageData = encoder.encode(message); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const signatureBuffer = await crypto.subtle.sign("HMAC", cryptoKey, messageData); + const signature = Array.from(new Uint8Array(signatureBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + const headers: Record = { - Authorization: `Bearer ${apiKey}`, + Authorization: `Bearer ${keys!.publicKey}`, + "X-Timestamp": timestamp, + "X-Signature": signature, "Content-Type": "application/json", }; @@ -108,7 +150,7 @@ async function apiRequest( }; if (data && method !== "GET") { - options.body = JSON.stringify(data); + options.body = body; } const response = await fetch(url, options); @@ -124,7 +166,7 @@ async function apiRequest( } async function cmdExecute(args: string[]) { - const apiKey = getApiKey(); + const keys = getApiKeys(); let sourceFile = ""; const envVars: Record = {}; const inputFiles: string[] = []; @@ -228,7 +270,7 @@ async function cmdExecute(args: string[]) { } // Execute - const result = await apiRequest("/execute", "POST", payload, apiKey); + const result = await apiRequest("/execute", "POST", payload, keys); // Print output if (result.stdout) { @@ -255,7 +297,7 @@ async function cmdExecute(args: string[]) { } async function cmdSession(args: string[]) { - const apiKey = getApiKey(); + const keys = getApiKeys(); let listMode = false; let killId = ""; let shell = ""; @@ -293,7 +335,7 @@ async function cmdSession(args: string[]) { } if (listMode) { - const result = await apiRequest("/sessions", "GET", undefined, apiKey); + const result = await apiRequest("/sessions", "GET", undefined, keys); const sessions = result.sessions || []; if (sessions.length === 0) { console.log("No active sessions"); @@ -311,7 +353,7 @@ async function cmdSession(args: string[]) { } if (killId) { - await apiRequest(`/sessions/${killId}`, "DELETE", undefined, apiKey); + await apiRequest(`/sessions/${killId}`, "DELETE", undefined, keys); console.log(`${GREEN}Session terminated: ${killId}${RESET}`); return; } @@ -324,7 +366,7 @@ async function cmdSession(args: string[]) { if (vcpu > 0) payload.vcpu = vcpu; console.log(`${YELLOW}Creating session...${RESET}`); - const result = await apiRequest("/sessions", "POST", payload, apiKey); + const result = await apiRequest("/sessions", "POST", payload, keys); console.log(`${GREEN}Session created: ${result.id}${RESET}`); console.log( `${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}`, @@ -332,7 +374,7 @@ async function cmdSession(args: string[]) { } async function cmdKey(args: string[]) { - const apiKey = getApiKey(); + const keys = getApiKeys(); let extend = false; // Parse arguments @@ -343,7 +385,7 @@ async function cmdKey(args: string[]) { } try { - const result = await apiRequest("/keys/validate", "POST", undefined, apiKey, PORTAL_BASE); + const result = await apiRequest("/keys/validate", "POST", undefined, keys, PORTAL_BASE); // Handle --extend flag if (extend) { @@ -395,7 +437,7 @@ async function cmdKey(args: string[]) { } async function cmdService(args: string[]) { - const apiKey = getApiKey(); + const keys = getApiKeys(); let listMode = false; let infoId = ""; let logsId = ""; @@ -475,7 +517,7 @@ async function cmdService(args: string[]) { } if (listMode) { - const result = await apiRequest("/services", "GET", undefined, apiKey); + const result = await apiRequest("/services", "GET", undefined, keys); const services = result.services || []; if (services.length === 0) { console.log("No services"); @@ -495,31 +537,31 @@ async function cmdService(args: string[]) { } if (infoId) { - const result = await apiRequest(`/services/${infoId}`, "GET", undefined, apiKey); + const result = await apiRequest(`/services/${infoId}`, "GET", undefined, keys); console.log(JSON.stringify(result, null, 2)); return; } if (logsId) { - const result = await apiRequest(`/services/${logsId}/logs`, "GET", undefined, apiKey); + const result = await apiRequest(`/services/${logsId}/logs`, "GET", undefined, keys); console.log(result.logs || ""); return; } if (sleepId) { - await apiRequest(`/services/${sleepId}/sleep`, "POST", undefined, apiKey); + await apiRequest(`/services/${sleepId}/sleep`, "POST", undefined, keys); console.log(`${GREEN}Service sleeping: ${sleepId}${RESET}`); return; } if (wakeId) { - await apiRequest(`/services/${wakeId}/wake`, "POST", undefined, apiKey); + await apiRequest(`/services/${wakeId}/wake`, "POST", undefined, keys); console.log(`${GREEN}Service waking: ${wakeId}${RESET}`); return; } if (destroyId) { - await apiRequest(`/services/${destroyId}`, "DELETE", undefined, apiKey); + await apiRequest(`/services/${destroyId}`, "DELETE", undefined, keys); console.log(`${GREEN}Service destroyed: ${destroyId}${RESET}`); return; } @@ -553,7 +595,7 @@ async function cmdService(args: string[]) { if (network) payload.network = network; if (vcpu > 0) payload.vcpu = vcpu; - const result = await apiRequest("/services", "POST", payload, apiKey); + const result = await apiRequest("/services", "POST", payload, keys); console.log(`${GREEN}Service created: ${result.id}${RESET}`); console.log(`Name: ${result.name}`); if (result.url) { diff --git a/un_inception.c b/un_inception.c index a21110e..3cce883 100644 --- a/un_inception.c +++ b/un_inception.c @@ -48,6 +48,7 @@ #include #include #include +#include #define API_BASE "https://api.unsandbox.com" #define PORTAL_BASE "https://unsandbox.com" @@ -56,6 +57,7 @@ #define GREEN "\033[32m" #define YELLOW "\033[33m" #define RESET "\033[0m" +#define MAX_CMD_LEN 16384 const char* detect_language(const char *filename) { const char *ext = strrchr(filename, '.'); @@ -113,7 +115,54 @@ void escape_json_char(FILE *out, char c) { } } -void cmd_execute(const char *source_file, char **envs, int env_count, char **files, int file_count, int artifacts, const char *output_dir, const char *network, int vcpu, const char *api_key) { +char* compute_hmac(const char *secret_key, const char *message) { + char cmd[MAX_CMD_LEN]; + snprintf(cmd, sizeof(cmd), "echo -n '%s' | openssl dgst -sha256 -hmac '%s' -hex | sed 's/.*= //'", message, secret_key); + + FILE *pipe = popen(cmd, "r"); + if (!pipe) return NULL; + + static char result[128]; + if (fgets(result, sizeof(result), pipe)) { + // Trim newline + size_t len = strlen(result); + while (len > 0 && (result[len-1] == '\n' || result[len-1] == '\r')) { + result[--len] = '\0'; + } + } + pclose(pipe); + return result; +} + +void get_timestamp(char *buffer, size_t bufsize) { + snprintf(buffer, bufsize, "%ld", (long)time(NULL)); +} + +void build_auth_headers(char *buffer, size_t bufsize, const char *method, const char *path, const char *body, const char *public_key, const char *secret_key) { + if (!secret_key || secret_key[0] == '\0') { + // Legacy mode: use public_key as bearer token + snprintf(buffer, bufsize, "-H 'Authorization: Bearer %s'", public_key); + return; + } + + // HMAC mode + char timestamp[32]; + get_timestamp(timestamp, sizeof(timestamp)); + + char message[MAX_CMD_LEN]; + snprintf(message, sizeof(message), "%s:%s:%s:%s", timestamp, method, path, body ? body : ""); + + char *signature = compute_hmac(secret_key, message); + if (!signature) { + fprintf(stderr, "%sError computing HMAC signature%s\n", RED, RESET); + exit(1); + } + + snprintf(buffer, bufsize, "-H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'", + public_key, timestamp, signature); +} + +void cmd_execute(const char *source_file, char **envs, int env_count, char **files, int file_count, int artifacts, const char *output_dir, const char *network, int vcpu, const char *public_key, const char *secret_key) { const char *language = detect_language(source_file); if (!language) { fprintf(stderr, "%sError: Cannot detect language%s\n", RED, RESET); @@ -179,13 +228,16 @@ void cmd_execute(const char *source_file, char **envs, int env_count, char **fil fclose(jsonf); // Make API request using curl - char cmd[8192]; + char auth_headers[1024]; + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", "/execute", json_body, public_key, secret_key); + + char cmd[MAX_CMD_LEN]; snprintf(cmd, sizeof(cmd), "curl -s -X POST '%s/execute' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer %s' " + "%s " "-d @- << 'EOF'\n%s\nEOF", - API_BASE, api_key, json_body); + API_BASE, auth_headers, json_body); FILE *curl = popen(cmd, "r"); if (!curl) { @@ -262,22 +314,27 @@ void cmd_execute(const char *source_file, char **envs, int env_count, char **fil exit(exit_code); } -void cmd_session(int list, const char *kill, const char *shell, const char *network, int vcpu, int tmux, int screen, const char *api_key) { - char cmd[4096]; +void cmd_session(int list, const char *kill, const char *shell, const char *network, int vcpu, int tmux, int screen, const char *public_key, const char *secret_key) { + char cmd[8192]; + char auth_headers[1024]; if (list) { + build_auth_headers(auth_headers, sizeof(auth_headers), "GET", "/sessions", "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X GET '%s/sessions' -H 'Authorization: Bearer %s'", - API_BASE, api_key); + "curl -s -X GET '%s/sessions' %s", + API_BASE, auth_headers); system(cmd); printf("\n"); return; } if (kill) { + char path[512]; + snprintf(path, sizeof(path), "/sessions/%s", kill); + build_auth_headers(auth_headers, sizeof(auth_headers), "DELETE", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X DELETE '%s/sessions/%s' -H 'Authorization: Bearer %s'", - API_BASE, kill, api_key); + "curl -s -X DELETE '%s/sessions/%s' %s", + API_BASE, kill, auth_headers); system(cmd); printf("%sSession terminated: %s%s\n", GREEN, kill, RESET); return; @@ -305,22 +362,25 @@ void cmd_session(int list, const char *kill, const char *shell, const char *netw strcat(json, "}"); printf("%sCreating session...%s\n", YELLOW, RESET); + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", "/sessions", json, public_key, secret_key); snprintf(cmd, sizeof(cmd), "curl -s -X POST '%s/sessions' -H 'Content-Type: application/json' " - "-H 'Authorization: Bearer %s' -d '%s'", - API_BASE, api_key, json); + "%s -d '%s'", + API_BASE, auth_headers, json); system(cmd); printf("\n%sSession created%s\n", GREEN, RESET); } -void cmd_key(int extend, const char *api_key) { - char cmd[4096]; +void cmd_key(int extend, const char *public_key, const char *secret_key) { + char cmd[8192]; + char auth_headers[1024]; + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", "/keys/validate", "", public_key, secret_key); snprintf(cmd, sizeof(cmd), "curl -s -X POST '%s/keys/validate' " "-H 'Content-Type: application/json' " - "-H 'Authorization: Bearer %s'", - PORTAL_BASE, api_key); + "%s", + PORTAL_BASE, auth_headers); FILE *curl = popen(cmd, "r"); if (!curl) { @@ -449,65 +509,85 @@ void cmd_key(int extend, const char *api_key) { } } -void cmd_service(const char *name, const char *ports, const char *domains, const char *service_type, const char *bootstrap, int list, const char *info, const char *logs, const char *tail, const char *sleep_svc, const char *wake, const char *destroy, const char *network, int vcpu, const char *api_key) { - char cmd[8192]; +void cmd_service(const char *name, const char *ports, const char *domains, const char *service_type, const char *bootstrap, int list, const char *info, const char *logs, const char *tail, const char *sleep_svc, const char *wake, const char *destroy, const char *network, int vcpu, const char *public_key, const char *secret_key) { + char cmd[16384]; + char auth_headers[1024]; if (list) { + build_auth_headers(auth_headers, sizeof(auth_headers), "GET", "/services", "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X GET '%s/services' -H 'Authorization: Bearer %s'", - API_BASE, api_key); + "curl -s -X GET '%s/services' %s", + API_BASE, auth_headers); system(cmd); printf("\n"); return; } if (info) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s", info); + build_auth_headers(auth_headers, sizeof(auth_headers), "GET", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X GET '%s/services/%s' -H 'Authorization: Bearer %s'", - API_BASE, info, api_key); + "curl -s -X GET '%s/services/%s' %s", + API_BASE, info, auth_headers); system(cmd); printf("\n"); return; } if (logs) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s/logs", logs); + build_auth_headers(auth_headers, sizeof(auth_headers), "GET", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X GET '%s/services/%s/logs' -H 'Authorization: Bearer %s'", - API_BASE, logs, api_key); + "curl -s -X GET '%s/services/%s/logs' %s", + API_BASE, logs, auth_headers); system(cmd); return; } if (tail) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s/logs", tail); + build_auth_headers(auth_headers, sizeof(auth_headers), "GET", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X GET '%s/services/%s/logs?lines=9000' -H 'Authorization: Bearer %s'", - API_BASE, tail, api_key); + "curl -s -X GET '%s/services/%s/logs?lines=9000' %s", + API_BASE, tail, auth_headers); system(cmd); return; } if (sleep_svc) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s/sleep", sleep_svc); + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X POST '%s/services/%s/sleep' -H 'Authorization: Bearer %s'", - API_BASE, sleep_svc, api_key); + "curl -s -X POST '%s/services/%s/sleep' %s", + API_BASE, sleep_svc, auth_headers); system(cmd); printf("%sService sleeping: %s%s\n", GREEN, sleep_svc, RESET); return; } if (wake) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s/wake", wake); + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X POST '%s/services/%s/wake' -H 'Authorization: Bearer %s'", - API_BASE, wake, api_key); + "curl -s -X POST '%s/services/%s/wake' %s", + API_BASE, wake, auth_headers); system(cmd); printf("%sService waking: %s%s\n", GREEN, wake, RESET); return; } if (destroy) { + char path[512]; + snprintf(path, sizeof(path), "/services/%s", destroy); + build_auth_headers(auth_headers, sizeof(auth_headers), "DELETE", path, "", public_key, secret_key); snprintf(cmd, sizeof(cmd), - "curl -s -X DELETE '%s/services/%s' -H 'Authorization: Bearer %s'", - API_BASE, destroy, api_key); + "curl -s -X DELETE '%s/services/%s' %s", + API_BASE, destroy, auth_headers); system(cmd); printf("%sService destroyed: %s%s\n", GREEN, destroy, RESET); return; @@ -551,10 +631,11 @@ void cmd_service(const char *name, const char *ports, const char *domains, const strcat(json, "}"); printf("%sCreating service...%s\n", YELLOW, RESET); + build_auth_headers(auth_headers, sizeof(auth_headers), "POST", "/services", json, public_key, secret_key); snprintf(cmd, sizeof(cmd), "curl -s -X POST '%s/services' -H 'Content-Type: application/json' " - "-H 'Authorization: Bearer %s' -d '%s'", - API_BASE, api_key, json); + "%s -d '%s'", + API_BASE, auth_headers, json); system(cmd); printf("\n%sService created%s\n", GREEN, RESET); return; @@ -565,10 +646,15 @@ void cmd_service(const char *name, const char *ports, const char *domains, const } int main(int argc, char *argv[]) { - const char *api_key = getenv("UNSANDBOX_API_KEY"); - if (!api_key && argc > 1 && strcmp(argv[1], "session") != 0 && strcmp(argv[1], "service") != 0) { - fprintf(stderr, "%sError: UNSANDBOX_API_KEY not set%s\n", RED, RESET); - return 1; + const char *public_key = getenv("UNSANDBOX_PUBLIC_KEY"); + const char *secret_key = getenv("UNSANDBOX_SECRET_KEY"); + + // Fall back to UNSANDBOX_API_KEY for backwards compatibility + if (!public_key) { + public_key = getenv("UNSANDBOX_API_KEY"); + } + if (!secret_key) { + secret_key = ""; } if (argc < 2) { @@ -584,9 +670,9 @@ int main(int argc, char *argv[]) { int extend = 0; for (int i = 2; i < argc; i++) { if (strcmp(argv[i], "--extend") == 0) extend = 1; - else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) api_key = argv[++i]; + else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) public_key = argv[++i]; } - cmd_key(extend, api_key); + cmd_key(extend, public_key, secret_key); return 0; } @@ -607,10 +693,10 @@ int main(int argc, char *argv[]) { else if (strcmp(argv[i], "-v") == 0 && i + 1 < argc) vcpu = atoi(argv[++i]); else if (strcmp(argv[i], "--tmux") == 0) tmux = 1; else if (strcmp(argv[i], "--screen") == 0) screen = 1; - else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) api_key = argv[++i]; + else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) public_key = argv[++i]; } - cmd_session(list, kill, shell, network, vcpu, tmux, screen, api_key); + cmd_session(list, kill, shell, network, vcpu, tmux, screen, public_key, secret_key); return 0; } @@ -645,10 +731,10 @@ int main(int argc, char *argv[]) { else if (strcmp(argv[i], "--destroy") == 0 && i + 1 < argc) destroy = argv[++i]; else if (strcmp(argv[i], "-n") == 0 && i + 1 < argc) network = argv[++i]; else if (strcmp(argv[i], "-v") == 0 && i + 1 < argc) vcpu = atoi(argv[++i]); - else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) api_key = argv[++i]; + else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) public_key = argv[++i]; } - cmd_service(name, ports, domains, service_type, bootstrap, list, info, logs, tail, sleep_svc, wake, destroy, network, vcpu, api_key); + cmd_service(name, ports, domains, service_type, bootstrap, list, info, logs, tail, sleep_svc, wake, destroy, network, vcpu, public_key, secret_key); return 0; } @@ -677,7 +763,7 @@ int main(int argc, char *argv[]) { } else if (strcmp(argv[i], "-v") == 0 && i + 1 < argc) { vcpu = atoi(argv[++i]); } else if (strcmp(argv[i], "-k") == 0 && i + 1 < argc) { - api_key = argv[++i]; + public_key = argv[++i]; } else if (!source_file && argv[i][0] != '-') { source_file = argv[i]; } @@ -688,6 +774,6 @@ int main(int argc, char *argv[]) { return 1; } - cmd_execute(source_file, envs, env_count, files, file_count, artifacts, output_dir, network, vcpu, api_key); + cmd_execute(source_file, envs, env_count, files, file_count, artifacts, output_dir, network, vcpu, public_key, secret_key); return 0; }