Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
safe_actions_tests.cpp
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // Safe Actions: Ed25519 against RFC 8032, the envelope against vectors from an |
| 2 | // independent implementation (tools/safe_actions_vectors.mjs), and sign/parse |
| 3 | // round trips through the public C ABI. |
| 4 | <ckey/ckey.h> |
| 5 | |
| 6 | <cctype> |
| 7 | <cstdint> |
| 8 | <cstdio> |
| 9 | <cstring> |
| 10 | <fstream> |
| 11 | <map> |
| 12 | <memory> |
| 13 | <sstream> |
| 14 | <string> |
| 15 | <vector> |
| 16 | |
| 17 | "check.h" |
| 18 | |
| 19 | namespace { |
| 20 | |
| 21 | std::vector<uint8_t> hex(const std::string &s) { |
| 22 | std::vector<uint8_t> v; |
| 23 | for (size_t i = 0; i + 1 < s.size(); i += 2) v.push_back(static_cast<uint8_t>(std::stoi(s.substr(i, 2), nullptr, 16))); |
| 24 | return v; |
| 25 | } |
| 26 | |
| 27 | // A small JSON reader: enough for the vector file (objects, arrays, strings, integers). |
| 28 | struct Json { |
| 29 | enum Kind { Null, Num, Str, Arr, Obj } kind = Null; |
| 30 | long long num = 0; |
| 31 | std::string str; |
| 32 | std::vector<Json> arr; |
| 33 | std::map<std::string, Json> obj; |
| 34 | const Json &operator[](const std::string &k) const { static Json none; auto it = obj.find(k); return it == obj.end() ? none : it->second; } |
| 35 | }; |
| 36 | |
| 37 | struct Reader { |
| 38 | const std::string &s; |
| 39 | size_t i = 0; |
| 40 | void ws() { while (i < s.size() && std::isspace(static_cast<unsigned char>(s[i]))) ++i; } |
| 41 | Json value() { |
| 42 | ws(); |
| 43 | Json j; |
| 44 | if (s[i] == '{') { |
| 45 | j.kind = Json::Obj; ++i; ws(); |
| 46 | if (s[i] == '}') { ++i; return j; } |
| 47 | while (true) { ws(); std::string k = string(); ws(); CKEY_CHECK(s[i] == ':'); ++i; j.obj[k] = value(); ws(); if (s[i] == ',') { ++i; continue; } CKEY_CHECK(s[i] == '}'); ++i; return j; } |
| 48 | } |
| 49 | if (s[i] == '[') { |
| 50 | j.kind = Json::Arr; ++i; ws(); |
| 51 | if (s[i] == ']') { ++i; return j; } |
| 52 | while (true) { j.arr.push_back(value()); ws(); if (s[i] == ',') { ++i; continue; } CKEY_CHECK(s[i] == ']'); ++i; return j; } |
| 53 | } |
| 54 | if (s[i] == '"') { j.kind = Json::Str; j.str = string(); return j; } |
| 55 | j.kind = Json::Num; |
| 56 | size_t end = i; |
| 57 | while (end < s.size() && (std::isdigit(static_cast<unsigned char>(s[end])) || s[end] == '-')) ++end; |
| 58 | j.num = std::stoll(s.substr(i, end - i)); |
| 59 | i = end; |
| 60 | return j; |
| 61 | } |
| 62 | std::string string() { |
| 63 | CKEY_CHECK(s[i] == '"'); ++i; |
| 64 | std::string out; |
| 65 | while (s[i] != '"') { |
| 66 | if (s[i] == '\\') { ++i; out.push_back(s[i] == 'n' ? '\n' : s[i]); ++i; } |
| 67 | else out.push_back(s[i++]); |
| 68 | } |
| 69 | ++i; |
| 70 | return out; |
| 71 | } |
| 72 | }; |
| 73 | |
| 74 | void test_rfc8032() { |
| 75 | // RFC 8032 section 7.1, tests 1 and 2, through the public key derivation and the envelope signer's key. |
| 76 | uint8_t pk[32]; |
| 77 | ckey_ed25519_public_key(hex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").data(), pk); |
| 78 | CKEY_CHECK(std::memcmp(pk, hex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a").data(), 32) == 0); |
| 79 | ckey_ed25519_public_key(hex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb").data(), pk); |
| 80 | CKEY_CHECK(std::memcmp(pk, hex("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c").data(), 32) == 0); |
| 81 | } |
| 82 | |
| 83 | void test_vectors(const char *path) { |
| 84 | std::ifstream f(path); |
| 85 | CKEY_CHECK(f.good()); |
| 86 | std::stringstream ss; |
| 87 | ss << f.rdbuf(); |
| 88 | const std::string text = ss.str(); |
| 89 | Reader r{text}; |
| 90 | const Json root = r.value(); |
| 91 | CKEY_CHECK(root["schema"].num == 1); |
| 92 | int checked = 0; |
| 93 | for (const Json &v : root["vectors"].arr) { |
| 94 | const std::string &payload = v["payload"].str; |
| 95 | const std::vector<uint8_t> key = hex(v["public_key"].str); |
| 96 | auto out = std::make_unique<ckey_safe_action>(); |
| 97 | ckey_diagnostics diag{}; |
| 98 | const ckey_status st = ckey_safe_action_verify(reinterpret_cast<const uint8_t *>(payload.data()), payload.size(), key.data(), |
| 99 | static_cast<uint64_t>(v["now"].num), static_cast<uint32_t>(v["max_lifetime"].num), out.get(), &diag); |
| 100 | const std::string &expect = v["expect"].str; |
| 101 | const bool ok = (expect == "ok" && st == CKEY_OK) || (expect == "integrity" && st == CKEY_INTEGRITY_FAILURE) || |
| 102 | (expect == "expired" && st == CKEY_EXPIRED) || (expect == "invalid" && st == CKEY_INVALID_ARGUMENT) || |
| 103 | (expect == "invalid_or_integrity" && (st == CKEY_INVALID_ARGUMENT || st == CKEY_INTEGRITY_FAILURE)); |
| 104 | if (!ok) std::fprintf(stderr, "vector \"%s\": expected %s, got status %d (%s)\n", v["name"].str.c_str(), expect.c_str(), st, diag.message); |
| 105 | CKEY_CHECK(ok); |
| 106 | if (expect == "ok") { |
| 107 | const Json &fields = v["fields"]; |
| 108 | CKEY_CHECK(out->scope == static_cast<uint32_t>(fields["scope"].num)); |
| 109 | CKEY_CHECK(fields["issuer"].str == out->issuer && fields["app"].str == out->app && fields["action"].str == out->action); |
| 110 | CKEY_CHECK(fields["key_id"].str == out->key_id && fields["request_id"].str == out->request_id); |
| 111 | CKEY_CHECK(out->issued_at == static_cast<uint64_t>(fields["issued_at"].num) && out->expires_at == static_cast<uint64_t>(fields["expires_at"].num)); |
| 112 | CKEY_CHECK(fields["params"].str == std::string(reinterpret_cast<const char *>(out->params), out->params_len)); |
| 113 | } |
| 114 | ++checked; |
| 115 | } |
| 116 | CKEY_CHECK(checked >= 20); |
| 117 | std::printf("safe actions: %d vectors\n", checked); |
| 118 | } |
| 119 | |
| 120 | void test_round_trip() { |
| 121 | const std::vector<uint8_t> seed = hex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"); |
| 122 | uint8_t pk[32]; |
| 123 | ckey_ed25519_public_key(seed.data(), pk); |
| 124 | auto fields = std::make_unique<ckey_safe_action>(); |
| 125 | fields->scope = CKEY_SCOPE_CROSS_DEVICE; |
| 126 | std::strcpy(fields->issuer, "citeory.com"); |
| 127 | std::strcpy(fields->app, "com.viadere"); |
| 128 | std::strcpy(fields->action, "signin.request"); |
| 129 | std::strcpy(fields->key_id, "2026-11"); |
| 130 | std::strcpy(fields->request_id, "sess-0123456789ab"); |
| 131 | fields->issued_at = 1794000000; |
| 132 | fields->expires_at = 1794000060; |
| 133 | const char params[] = "{\"return\":\"/dashboard\"}"; |
| 134 | std::memcpy(fields->params, params, sizeof(params) - 1); |
| 135 | fields->params_len = sizeof(params) - 1; |
| 136 | size_t need = 0; |
| 137 | ckey_diagnostics diag{}; |
| 138 | CKEY_CHECK(ckey_safe_action_sign(fields.get(), seed.data(), nullptr, 0, &need, &diag) == CKEY_INSUFFICIENT_BUFFER); |
| 139 | std::vector<char> text(need + 1); |
| 140 | CKEY_CHECK(ckey_safe_action_sign(fields.get(), seed.data(), text.data(), text.size(), &need, &diag) == CKEY_OK); |
| 141 | const auto *bytes = reinterpret_cast<const uint8_t *>(text.data()); |
| 142 | CKEY_CHECK(ckey_is_safe_action(bytes, need) == 1); |
| 143 | CKEY_CHECK(ckey_is_safe_action(reinterpret_cast<const uint8_t *>("https://x"), 9) == 0); |
| 144 | auto parsed = std::make_unique<ckey_safe_action>(); |
| 145 | CKEY_CHECK(ckey_safe_action_parse(bytes, need, parsed.get(), &diag) == CKEY_OK); |
| 146 | CKEY_CHECK(std::strcmp(parsed->action, "signin.request") == 0 && parsed->scope == CKEY_SCOPE_CROSS_DEVICE); |
| 147 | auto verified = std::make_unique<ckey_safe_action>(); |
| 148 | CKEY_CHECK(ckey_safe_action_verify(bytes, need, pk, 1794000030, 600, verified.get(), &diag) == CKEY_OK); |
| 149 | CKEY_CHECK(std::memcmp(verified->params, params, sizeof(params) - 1) == 0); |
| 150 | CKEY_CHECK(ckey_safe_action_verify(bytes, need, pk, 1794000061, 600, verified.get(), &diag) == CKEY_EXPIRED); |
| 151 | CKEY_CHECK(ckey_safe_action_verify(bytes, need, pk, 1794000030, 30, verified.get(), &diag) == CKEY_EXPIRED); |
| 152 | // Every single-character change to the envelope is refused. |
| 153 | const size_t prefix = std::strlen("ckey-action:1:"); |
| 154 | int refused = 0; |
| 155 | for (size_t i = prefix; i < need; ++i) { |
| 156 | std::string changed(text.data(), need); |
| 157 | changed[i] = changed[i] == 'A' ? 'B' : 'A'; |
| 158 | if (ckey_safe_action_verify(reinterpret_cast<const uint8_t *>(changed.data()), changed.size(), pk, 1794000030, 600, verified.get(), &diag) != CKEY_OK) ++refused; |
| 159 | } |
| 160 | CKEY_CHECK(refused == static_cast<int>(need - prefix)); |
| 161 | // Fields the format cannot carry are refused at signing time. |
| 162 | std::strcpy(fields->issuer, "Not A Domain"); |
| 163 | CKEY_CHECK(ckey_safe_action_sign(fields.get(), seed.data(), text.data(), text.size(), &need, &diag) == CKEY_INVALID_ARGUMENT); |
| 164 | std::strcpy(fields->issuer, "citeory.com"); |
| 165 | fields->params_len = 2000; |
| 166 | CKEY_CHECK(ckey_safe_action_sign(fields.get(), seed.data(), text.data(), text.size(), &need, &diag) == CKEY_INVALID_ARGUMENT); |
| 167 | std::puts("safe actions: round trip, tamper and field checks"); |
| 168 | } |
| 169 | |
| 170 | } // namespace |
| 171 | |
| 172 | int main(int argc, char **argv) { |
| 173 | test_rfc8032(); |
| 174 | test_round_trip(); |
| 175 | test_vectors(argc > 1 ? argv[1] : "tests/vectors/safe-actions-v1.json"); |
| 176 | return 0; |
| 177 | } |