diff --git a/CLAUDE.md b/CLAUDE.md index 15e3232..57bff24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,10 @@ make status # check what's running - **FIPS 140-3** via `GOEXPERIMENT=boringcrypto` — never build without it - **Zero-knowledge** — server never sees plaintext credentials -- **WebAuthn L2** — hardware key support (in progress) +- **WebAuthn PRF** — hardware key derives master secret; L2 (16 bytes) for agents, L3 (32 bytes) for humans only - No logging of credential content, ever +- **Registration = unlocked.** Passkey registration MUST derive and store the master key. There is no distinction between "registered" and "logged in" — both mean the user authenticated with hardware. The vault is immediately usable after registration, no second tap. +- **No "simplest fix" shortcuts.** This is a world-class security product. Every flow must be correct by design, not patched after the fact. ## Current Status (Mar 2026) diff --git a/Makefile b/Makefile index 0853ffa..a9f4d55 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ WEB_DIR := website CLI_DIR := cli APP_BIN := $(APP_DIR)/vault1984 WEB_BIN := $(WEB_DIR)/vault1984-web -CLI_BIN := $(CLI_DIR)/v1984 +CLI_BIN := $(CLI_DIR)/vault1984-cli APP_ENTRY := ./cmd/vault1984 WEB_ENTRY := . diff --git a/cli/Makefile b/cli/Makefile index c62aada..3cd0712 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -35,7 +35,7 @@ CJSON_DIR := $(VENDOR_DIR)/cjson CRYPTO_DIR := ../crypto # Output binary -BIN := v1984 +BIN := vault1984-cli # --- Source files --- diff --git a/cli/build/src/keystore.o b/cli/build/src/keystore.o index 0aacd3d..20f2167 100644 Binary files a/cli/build/src/keystore.o and b/cli/build/src/keystore.o differ diff --git a/cli/build/src/main.o b/cli/build/src/main.o index 6340b5a..91a5d08 100644 Binary files a/cli/build/src/main.o and b/cli/build/src/main.o differ diff --git a/cli/src/keystore.c b/cli/src/keystore.c index 48ee4c2..a4216b5 100644 --- a/cli/src/keystore.c +++ b/cli/src/keystore.c @@ -35,7 +35,7 @@ #endif /* Config encryption key — derived from a string that also appears in the web UI */ -static const char CONFIG_SEED[] = "vault1984-config-v1"; +static const char CONFIG_SEED[] = "vault1984-l2-"; static const unsigned char CONFIG_MAGIC[4] = { 'V', '1', '9', 0x01 }; /* Derive 16-byte config encryption key from seed */ diff --git a/cli/src/main.c b/cli/src/main.c index 1e17e15..5863e21 100644 --- a/cli/src/main.c +++ b/cli/src/main.c @@ -1,5 +1,5 @@ /* - * v1984 CLI — credential access for AI agents + * vault1984-cli CLI — credential access for AI agents * Copyright (c) 2026 Vault1984. Elastic License 2.0. */ @@ -19,25 +19,20 @@ static void usage(void) { fprintf(stderr, - "v1984 %s — credential access for AI agents\n" + "vault1984-cli %s — credential access for AI agents\n" "\n" - "Setup:\n" - " v1984 config --vault --agent --l2 \n" - "\n" - "Commands:\n" - " v1984 get [--json]\n" - " v1984 list [filter] [--json]\n" - " v1984 totp \n" - " v1984 test-totp \n" - " v1984 test-crypto\n" + "Usage:\n" + " vault1984-cli --token get \n" + " vault1984-cli --token list [filter]\n" + " vault1984-cli --token totp \n" + " vault1984-cli test-totp \n" + " vault1984-cli test-crypto\n" "\n" "Options:\n" - " --help Show this help\n" - " --version Show version\n" + " --token Agent token (from vault1984 web UI)\n" " --json Output as JSON\n" - "\n" - "The config command stores vault URL, agent name, and L2 key\n" - "in an encrypted local config file. Run once per agent.\n", + " --help Show this help\n" + " --version Show version\n", VERSION); } @@ -54,7 +49,7 @@ static void get_bearer(const struct v84_config *cfg, char *buf, size_t len) { /* --- test commands --- */ static int cmd_test_crypto(void) { - fprintf(stderr, "v1984: crypto self-test\n"); + fprintf(stderr, "vault1984-cli: crypto self-test\n"); /* BearSSL AES-128-GCM roundtrip */ { @@ -180,11 +175,11 @@ int main(int argc, char **argv) { /* No-config commands */ if (strcmp(cmd, "--help") == 0 || strcmp(cmd, "-h") == 0) { usage(); return 0; } - if (strcmp(cmd, "--version") == 0) { printf("v1984 %s\n", VERSION); return 0; } + if (strcmp(cmd, "--version") == 0) { printf("vault1984-cli %s\n", VERSION); return 0; } if (strcmp(cmd, "test-crypto") == 0) { return cmd_test_crypto(); } if (strcmp(cmd, "test-totp") == 0) { if (argc < 3) { - fprintf(stderr, "usage: v1984 test-totp \n"); + fprintf(stderr, "usage: vault1984-cli test-totp \n"); return 1; } if (jsbridge_init() != 0) { fprintf(stderr, "error: crypto init failed\n"); return 1; } @@ -198,43 +193,94 @@ int main(int argc, char **argv) { return code ? 0 : 1; } - /* Config command — one-time setup */ - if (strcmp(cmd, "config") == 0) { - const char *vault = NULL, *agent = NULL, *l2_b64 = NULL; - for (int i = 2; i < argc; i++) { - if (strcmp(argv[i], "--vault") == 0 && i + 1 < argc) vault = argv[++i]; - else if (strcmp(argv[i], "--agent") == 0 && i + 1 < argc) agent = argv[++i]; - else if (strcmp(argv[i], "--l2") == 0 && i + 1 < argc) l2_b64 = argv[++i]; + /* Parse --token and find the command */ + const char *token_b64 = NULL; + const char *command = NULL; + int cmd_start = 0; + int json_output = 0; + + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--token") == 0 && i + 1 < argc) { + token_b64 = argv[++i]; + } else if (strcmp(argv[i], "--json") == 0) { + json_output = 1; + } else if (argv[i][0] != '-' && !command) { + command = argv[i]; + cmd_start = i + 1; } - if (!vault || !agent || !l2_b64) { - fprintf(stderr, "usage: v1984 config --vault --agent --l2 \n"); - return 1; - } - unsigned char l2_key[32]; - size_t l2_len = 0; - if (base64_decode(l2_b64, l2_key, sizeof(l2_key), &l2_len) != 0 || l2_len != 16) { - fprintf(stderr, "error: L2 key must be 16 bytes (24 base64 chars)\n"); - return 1; - } - int ret = keystore_init(vault, agent, l2_key, l2_len); - memset(l2_key, 0, sizeof(l2_key)); - return ret; } - /* Commands that need config */ + if (!command) { fprintf(stderr, "error: no command\n"); usage(); return 1; } + if (!token_b64) { fprintf(stderr, "error: --token required\n"); usage(); return 1; } + + /* Decode token: base64url → AES-128-GCM encrypted blob → decrypt → split */ + if (jsbridge_init() != 0) { fprintf(stderr, "error: crypto init failed\n"); return 1; } + + /* Decrypt token via JS (same key derivation as browser) */ + char js_code[2048]; + snprintf(js_code, sizeof(js_code), + "(function() {" + " var enc = new TextEncoder();" + " var seed = enc.encode('vault1984-l2-');" + " var encKey = native_hkdf_sha256(seed, null, enc.encode('token'), 16);" + " var ct = native_base64_decode('%s');" + " var pt = native_aes_gcm_decrypt_blob(encKey, ct);" + " return native_base64_encode(pt);" + "})()", token_b64); + char *decrypted_b64 = jsbridge_eval(js_code); + if (!decrypted_b64) { + fprintf(stderr, "error: token decryption failed (invalid or corrupted token)\n"); + jsbridge_cleanup(); + return 1; + } + + unsigned char token_raw[512]; + size_t token_len = 0; + if (base64_decode(decrypted_b64, token_raw, sizeof(token_raw), &token_len) != 0 || token_len < 3) { + fprintf(stderr, "error: invalid token payload\n"); + free(decrypted_b64); + jsbridge_cleanup(); + return 1; + } + free(decrypted_b64); + + /* Split on null bytes: host \0 agent_name \0 l2_key_16_bytes */ + char *vault_host = (char *)token_raw; + char *sep1 = memchr(token_raw, '\0', token_len); + if (!sep1 || (size_t)(sep1 - (char *)token_raw) >= token_len - 1) { + fprintf(stderr, "error: malformed token\n"); + jsbridge_cleanup(); + return 1; + } + char *agent_name = sep1 + 1; + char *sep2 = memchr(agent_name, '\0', token_len - (size_t)(agent_name - (char *)token_raw)); + if (!sep2) { + fprintf(stderr, "error: malformed token\n"); + jsbridge_cleanup(); + return 1; + } + unsigned char *l2_key = (unsigned char *)(sep2 + 1); + size_t l2_len = token_len - (size_t)(l2_key - token_raw); + if (l2_len != 16) { + fprintf(stderr, "error: invalid L2 key in token (%zu bytes, need 16)\n", l2_len); + jsbridge_cleanup(); + return 1; + } + + /* Build config from token */ struct v84_config cfg; - if (keystore_load(&cfg) != 0) return 1; + memset(&cfg, 0, sizeof(cfg)); + snprintf(cfg.vault_url, sizeof(cfg.vault_url), "https://%s:1984", vault_host); + snprintf(cfg.agent_name, sizeof(cfg.agent_name), "%s", agent_name); + memcpy(cfg.l2_key, l2_key, 16); + + /* L1 = first 8 bytes of L2 key, used as Bearer auth */ char bearer[32]; get_bearer(&cfg, bearer, sizeof(bearer)); - int json_output = 0; - for (int i = 2; i < argc; i++) { - if (strcmp(argv[i], "--json") == 0) json_output = 1; - } - - if (strcmp(cmd, "list") == 0) { + if (strcmp(command, "list") == 0) { const char *filter = NULL; - for (int i = 2; i < argc; i++) { + for (int i = cmd_start; i < argc; i++) { if (argv[i][0] != '-') { filter = argv[i]; break; } } @@ -268,13 +314,13 @@ int main(int argc, char **argv) { return 0; } - if (strcmp(cmd, "get") == 0 || strcmp(cmd, "totp") == 0) { - int is_totp = (strcmp(cmd, "totp") == 0); + if (strcmp(command, "get") == 0 || strcmp(command, "totp") == 0) { + int is_totp = (strcmp(command, "totp") == 0); const char *query = NULL; - for (int i = 2; i < argc; i++) { + for (int i = cmd_start; i < argc; i++) { if (argv[i][0] != '-') { query = argv[i]; break; } } - if (!query) { fprintf(stderr, "error: query required\nusage: v1984 %s \n", cmd); return 1; } + if (!query) { fprintf(stderr, "error: query required\nusage: vault1984-cli --token TOKEN %s \n", command); return 1; } /* Search */ char url[1024], encoded[512], path[768]; @@ -337,7 +383,7 @@ int main(int argc, char **argv) { return 0; } - fprintf(stderr, "error: unknown command '%s'\n", cmd); + fprintf(stderr, "error: unknown command '%s'\n", command); usage(); return 1; } diff --git a/cli/vault1984-cli b/cli/vault1984-cli new file mode 100755 index 0000000..3f51204 Binary files /dev/null and b/cli/vault1984-cli differ diff --git a/crypto/crypto.js b/crypto/crypto.js index 3282af3..97a6f1c 100644 --- a/crypto/crypto.js +++ b/crypto/crypto.js @@ -121,27 +121,31 @@ function hkdf_sha256(ikm, salt, info, length) { } } -/* --- L2 field encryption/decryption --- */ +/* --- Field encryption/decryption --- */ /** - * Encrypt a field value with L2 key (AES-128-GCM). - * In QuickJS: returns base64 string (synchronous). - * In browser: returns Promise. + * Encrypt a field value. + * Key length determines tier: 16 bytes = L2 (AES-128), 32 bytes = L3 (AES-256). + * @param {Uint8Array} key - 16 or 32 bytes + * @param {string} field_label - field label (for per-field key derivation) + * @param {string} plaintext - field value to encrypt + * @returns {string|Promise} base64-encoded ciphertext */ -function l2_encrypt_field(l2_key, entry_id, field_label, plaintext) { - var info_str = 'vault1984-l2-' + entry_id + '-' + field_label; +function encrypt_field(key, field_label, plaintext) { + var info_str = 'vault1984-field-' + field_label; + var key_len = key.length; if (IS_BROWSER) { var enc = new TextEncoder(); var info = enc.encode(info_str); - return hkdf_sha256(l2_key, null, info, 16).then(function(field_key) { + return hkdf_sha256(key, null, info, key_len).then(function(field_key) { return aes_gcm_encrypt(field_key, enc.encode(plaintext)); }).then(function(ct) { return uint8_to_base64(ct); }); } else { var info = native_encode_utf8(info_str); - var field_key = native_hkdf_sha256(l2_key, null, info, 16); + var field_key = native_hkdf_sha256(key, null, info, key_len); var pt_bytes = native_encode_utf8(plaintext); var ct = native_aes_gcm_encrypt(field_key, pt_bytes); return native_base64_encode(ct); @@ -149,18 +153,22 @@ function l2_encrypt_field(l2_key, entry_id, field_label, plaintext) { } /** - * Decrypt a field value with L2 key (AES-128-GCM). - * In QuickJS: returns plaintext string (synchronous). - * In browser: returns Promise. + * Decrypt a field value. + * Key length determines tier: 16 bytes = L2, 32 bytes = L3. + * @param {Uint8Array} key - 16 or 32 bytes + * @param {string} field_label - field label + * @param {string} ciphertext_b64 - base64-encoded ciphertext + * @returns {string|Promise} plaintext */ -function l2_decrypt_field(l2_key, entry_id, field_label, ciphertext_b64) { - var info_str = 'vault1984-l2-' + entry_id + '-' + field_label; +function decrypt_field(key, field_label, ciphertext_b64) { + var info_str = 'vault1984-field-' + field_label; + var key_len = key.length; if (IS_BROWSER) { var enc = new TextEncoder(); var dec = new TextDecoder(); var info = enc.encode(info_str); - return hkdf_sha256(l2_key, null, info, 16).then(function(field_key) { + return hkdf_sha256(key, null, info, key_len).then(function(field_key) { var ct = base64_to_uint8(ciphertext_b64); return aes_gcm_decrypt(field_key, ct); }).then(function(pt) { @@ -168,19 +176,25 @@ function l2_decrypt_field(l2_key, entry_id, field_label, ciphertext_b64) { }); } else { var info = native_encode_utf8(info_str); - var field_key = native_hkdf_sha256(l2_key, null, info, 16); + var field_key = native_hkdf_sha256(key, null, info, key_len); var ct = native_base64_decode(ciphertext_b64); var pt = native_aes_gcm_decrypt_blob(field_key, ct); return native_decode_utf8(pt); } } +/* Backward compat aliases */ +function l2_encrypt_field(key, entry_id, label, pt) { return encrypt_field(key, label, pt); } +function l2_decrypt_field(key, entry_id, label, ct) { return decrypt_field(key, label, ct); } + /* Export for both environments */ if (typeof globalThis.vault1984 === 'undefined') globalThis.vault1984 = {}; globalThis.vault1984.crypto = { aes_gcm_encrypt: aes_gcm_encrypt, aes_gcm_decrypt: aes_gcm_decrypt, hkdf_sha256: hkdf_sha256, + encrypt_field: encrypt_field, + decrypt_field: decrypt_field, l2_encrypt_field: l2_encrypt_field, l2_decrypt_field: l2_decrypt_field, uint8_to_base64: uint8_to_base64,