Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
api_safe_actions.inc
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // Public C ABI: Safe Actions (spec/CKey-Safe-Actions.md). Included after core_internal.inc. |
| 2 | // |
| 3 | // A Safe Action payload is the text "ckey-action:1:" followed by the base64url |
| 4 | // (no padding) of a signed envelope: |
| 5 | // |
| 6 | // version u8 (1) | scope u8 (0 same-device, 1 cross-device) |
| 7 | // issuer u8 len + bytes DNS name of the issuing site, lower case |
| 8 | // app u8 len + bytes ID of the receiving app, e.g. com.viadere |
| 9 | // action u8 len + bytes an action the receiving app registered |
| 10 | // key_id u8 len + bytes which of the issuer's keys signed it |
| 11 | // request u8 len + bytes single-use request ID, 8..64 characters |
| 12 | // issued_at u64 BE, expires_at u64 BE Unix seconds |
| 13 | // params u16 BE len + bytes at most 1024; UTF-8 JSON is recommended |
| 14 | // signature 64 bytes Ed25519 over "CKey Safe Action v1\0" + every byte above |
| 15 | // |
| 16 | // The core parses, signs and verifies. Whether an action may run is decided by |
| 17 | // the SDKs from the verified result and what the receiving app registered. |
| 18 | |
| 19 | "ed25519.inc" |
| 20 | |
| 21 | namespace { |
| 22 | |
| 23 | constexpr char kSafeActionPrefix[] = "ckey-action:1:"; |
| 24 | constexpr size_t kSafeActionPrefixLen = sizeof(kSafeActionPrefix) - 1; |
| 25 | constexpr char kSafeActionContext[] = "CKey Safe Action v1"; // signed with its terminating NUL |
| 26 | |
| 27 | bool sa_issuer_ok(const uint8_t *p, size_t n) { |
| 28 | if (n < 1 || n > 253 || p[0] == '.' || p[n - 1] == '.') return false; |
| 29 | bool dot = false; |
| 30 | for (size_t i = 0; i < n; ++i) { |
| 31 | const uint8_t c = p[i]; |
| 32 | if (c == '.') { if (i && p[i - 1] == '.') return false; dot = true; continue; } |
| 33 | if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-')) return false; |
| 34 | } |
| 35 | return dot; |
| 36 | } |
| 37 | |
| 38 | bool sa_token_ok(const uint8_t *p, size_t n, size_t min, size_t max, bool lower_only, bool allow_dot) { |
| 39 | if (n < min || n > max) return false; |
| 40 | for (size_t i = 0; i < n; ++i) { |
| 41 | const uint8_t c = p[i]; |
| 42 | const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || |
| 43 | (!lower_only && c >= 'A' && c <= 'Z') || (allow_dot && c == '.'); |
| 44 | if (!ok) return false; |
| 45 | } |
| 46 | return true; |
| 47 | } |
| 48 | |
| 49 | int b64url_value(uint8_t c) { |
| 50 | if (c >= 'A' && c <= 'Z') return c - 'A'; |
| 51 | if (c >= 'a' && c <= 'z') return c - 'a' + 26; |
| 52 | if (c >= '0' && c <= '9') return c - '0' + 52; |
| 53 | if (c == '-') return 62; |
| 54 | if (c == '_') return 63; |
| 55 | return -1; |
| 56 | } |
| 57 | |
| 58 | bool b64url_decode(const uint8_t *in, size_t n, std::vector<uint8_t> &out) { |
| 59 | if (n % 4 == 1) return false; |
| 60 | out.clear(); |
| 61 | out.reserve(n * 3 / 4); |
| 62 | uint32_t acc = 0; |
| 63 | int bits = 0; |
| 64 | for (size_t i = 0; i < n; ++i) { |
| 65 | const int v = b64url_value(in[i]); |
| 66 | if (v < 0) return false; |
| 67 | acc = (acc << 6) | static_cast<uint32_t>(v); |
| 68 | bits += 6; |
| 69 | if (bits >= 8) { |
| 70 | bits -= 8; |
| 71 | out.push_back(static_cast<uint8_t>(acc >> bits)); |
| 72 | } |
| 73 | } |
| 74 | // Unused low bits must be zero, so each envelope has exactly one text form. |
| 75 | return (acc & ((1u << bits) - 1)) == 0; |
| 76 | } |
| 77 | |
| 78 | void b64url_encode(const std::vector<uint8_t> &in, std::string &out) { |
| 79 | static const char kAlphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; |
| 80 | uint32_t acc = 0; |
| 81 | int bits = 0; |
| 82 | for (uint8_t b : in) { |
| 83 | acc = (acc << 8) | b; |
| 84 | bits += 8; |
| 85 | while (bits >= 6) { bits -= 6; out.push_back(kAlphabet[(acc >> bits) & 63]); } |
| 86 | } |
| 87 | if (bits) out.push_back(kAlphabet[(acc << (6 - bits)) & 63]); |
| 88 | } |
| 89 | |
| 90 | // Parses the decoded envelope into out; returns the offset of the signature, or 0 on error. |
| 91 | size_t sa_parse_bytes(const std::vector<uint8_t> &b, ckey_safe_action *out, const char **why) { |
| 92 | size_t at = 0; |
| 93 | auto need = [&](size_t n) { return at + n <= b.size(); }; |
| 94 | if (!need(2) || b[0] != 1) { *why = "unsupported Safe Action version"; return 0; } |
| 95 | if (b[1] > 1) { *why = "unknown scope"; return 0; } |
| 96 | std::memset(out, 0, sizeof(*out)); |
| 97 | out->abi_version = CKEY_ABI_VERSION; |
| 98 | out->scope = b[1]; |
| 99 | at = 2; |
| 100 | struct Field { char *dest; size_t cap; const char *name; int kind; size_t min, max; }; |
| 101 | const Field fields[] = { |
| 102 | {out->issuer, sizeof(out->issuer), "issuer", 0, 1, 253}, |
| 103 | {out->app, sizeof(out->app), "app", 1, 1, 255}, |
| 104 | {out->action, sizeof(out->action), "action", 2, 1, 64}, |
| 105 | {out->key_id, sizeof(out->key_id), "key ID", 3, 1, 32}, |
| 106 | {out->request_id, sizeof(out->request_id), "request ID", 4, 8, 64}, |
| 107 | }; |
| 108 | for (const Field &f : fields) { |
| 109 | if (!need(1)) { *why = "truncated envelope"; return 0; } |
| 110 | const size_t n = b[at++]; |
| 111 | if (!need(n)) { *why = "truncated envelope"; return 0; } |
| 112 | const uint8_t *p = b.data() + at; |
| 113 | const bool ok = f.kind == 0 ? sa_issuer_ok(p, n) |
| 114 | : f.kind == 1 ? sa_token_ok(p, n, f.min, f.max, false, true) |
| 115 | : f.kind == 2 ? sa_token_ok(p, n, f.min, f.max, true, true) |
| 116 | : f.kind == 3 ? sa_token_ok(p, n, f.min, f.max, false, true) |
| 117 | : sa_token_ok(p, n, f.min, f.max, false, false); |
| 118 | if (!ok || n + 1 > f.cap) { *why = f.name; return 0; } |
| 119 | std::memcpy(f.dest, p, n); |
| 120 | f.dest[n] = '\0'; |
| 121 | at += n; |
| 122 | } |
| 123 | if (!need(18)) { *why = "truncated envelope"; return 0; } |
| 124 | for (int i = 0; i < 8; ++i) out->issued_at = (out->issued_at << 8) | b[at++]; |
| 125 | for (int i = 0; i < 8; ++i) out->expires_at = (out->expires_at << 8) | b[at++]; |
| 126 | const size_t plen = (size_t(b[at]) << 8) | b[at + 1]; |
| 127 | at += 2; |
| 128 | if (plen > sizeof(out->params)) { *why = "params longer than 1024 bytes"; return 0; } |
| 129 | if (!need(plen)) { *why = "truncated envelope"; return 0; } |
| 130 | std::memcpy(out->params, b.data() + at, plen); |
| 131 | out->params_len = plen; |
| 132 | at += plen; |
| 133 | if (b.size() != at + 64) { *why = "missing or extra signature bytes"; return 0; } |
| 134 | return at; |
| 135 | } |
| 136 | |
| 137 | ckey_status sa_decode(const uint8_t *payload, size_t len, std::vector<uint8_t> &bytes, ckey_safe_action *out, |
| 138 | size_t *sig_at, ckey_diagnostics *diag) { |
| 139 | if (!payload || !out) { set_status(diag, CKEY_INVALID_ARGUMENT, "payload and output are required"); return CKEY_INVALID_ARGUMENT; } |
| 140 | if (len <= kSafeActionPrefixLen || std::memcmp(payload, kSafeActionPrefix, kSafeActionPrefixLen) != 0) { |
| 141 | set_status(diag, CKEY_INVALID_ARGUMENT, "not a Safe Action (no ckey-action:1: prefix)"); |
| 142 | return CKEY_INVALID_ARGUMENT; |
| 143 | } |
| 144 | if (len > kSafeActionPrefixLen + 4096 || !b64url_decode(payload + kSafeActionPrefixLen, len - kSafeActionPrefixLen, bytes)) { |
| 145 | set_status(diag, CKEY_INVALID_ARGUMENT, "Safe Action envelope is not valid base64url"); |
| 146 | return CKEY_INVALID_ARGUMENT; |
| 147 | } |
| 148 | const char *why = "malformed envelope"; |
| 149 | *sig_at = sa_parse_bytes(bytes, out, &why); |
| 150 | if (!*sig_at) { |
| 151 | std::memset(out, 0, sizeof(*out)); |
| 152 | set_status(diag, CKEY_INVALID_ARGUMENT, (std::string("invalid Safe Action: ") + why).c_str()); |
| 153 | return CKEY_INVALID_ARGUMENT; |
| 154 | } |
| 155 | return CKEY_OK; |
| 156 | } |
| 157 | |
| 158 | } // namespace |
| 159 | |
| 160 | extern "C" { |
| 161 | |
| 162 | int ckey_is_safe_action(const uint8_t *payload, size_t len) { |
| 163 | return payload && len > kSafeActionPrefixLen && std::memcmp(payload, kSafeActionPrefix, kSafeActionPrefixLen) == 0; |
| 164 | } |
| 165 | |
| 166 | ckey_status ckey_safe_action_parse(const uint8_t *payload, size_t len, ckey_safe_action *out, ckey_diagnostics *diag) { |
| 167 | std::vector<uint8_t> bytes; |
| 168 | size_t sig_at = 0; |
| 169 | const ckey_status st = sa_decode(payload, len, bytes, out, &sig_at, diag); |
| 170 | if (st == CKEY_OK) set_status(diag, CKEY_OK, "parsed; the signature has not been checked"); |
| 171 | return st; |
| 172 | } |
| 173 | |
| 174 | ckey_status ckey_safe_action_verify(const uint8_t *payload, size_t len, const uint8_t public_key[32], uint64_t now, |
| 175 | uint32_t max_lifetime_seconds, ckey_safe_action *out, ckey_diagnostics *diag) { |
| 176 | if (!public_key) { set_status(diag, CKEY_INVALID_ARGUMENT, "a public key is required"); return CKEY_INVALID_ARGUMENT; } |
| 177 | std::vector<uint8_t> bytes; |
| 178 | size_t sig_at = 0; |
| 179 | ckey_status st = sa_decode(payload, len, bytes, out, &sig_at, diag); |
| 180 | if (st != CKEY_OK) return st; |
| 181 | std::vector<uint8_t> message(kSafeActionContext, kSafeActionContext + sizeof(kSafeActionContext)); |
| 182 | message.insert(message.end(), bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(sig_at)); |
| 183 | if (!ckey_crypto::ed25519_verify(bytes.data() + sig_at, message.data(), message.size(), public_key)) { |
| 184 | std::memset(out, 0, sizeof(*out)); |
| 185 | set_status(diag, CKEY_INTEGRITY_FAILURE, "the signature does not match this key"); |
| 186 | return CKEY_INTEGRITY_FAILURE; |
| 187 | } |
| 188 | const uint64_t lifetime = max_lifetime_seconds ? max_lifetime_seconds : 600; |
| 189 | const uint64_t skew = 120; // tolerated clock difference, seconds |
| 190 | const char *expired = nullptr; |
| 191 | if (out->expires_at <= out->issued_at) expired = "expires before it is issued"; |
| 192 | else if (out->expires_at - out->issued_at > lifetime) expired = "valid for longer than this app allows"; |
| 193 | else if (now + skew < out->issued_at) expired = "issued in the future"; |
| 194 | else if (now >= out->expires_at) expired = "expired"; |
| 195 | if (expired) { |
| 196 | set_status(diag, CKEY_EXPIRED, (std::string("Safe Action ") + expired).c_str()); |
| 197 | return CKEY_EXPIRED; // out keeps the parsed, verified fields for diagnostics |
| 198 | } |
| 199 | set_status(diag, CKEY_OK, "verified"); |
| 200 | return CKEY_OK; |
| 201 | } |
| 202 | |
| 203 | ckey_status ckey_safe_action_sign(const ckey_safe_action *fields, const uint8_t seed[32], char *out, size_t out_capacity, |
| 204 | size_t *out_len, ckey_diagnostics *diag) { |
| 205 | if (!fields || !seed) { set_status(diag, CKEY_INVALID_ARGUMENT, "fields and a signing seed are required"); return CKEY_INVALID_ARGUMENT; } |
| 206 | if (fields->scope > 1) { set_status(diag, CKEY_INVALID_ARGUMENT, "unknown scope"); return CKEY_INVALID_ARGUMENT; } |
| 207 | std::vector<uint8_t> b = {1, static_cast<uint8_t>(fields->scope)}; |
| 208 | const char *strings[] = {fields->issuer, fields->app, fields->action, fields->key_id, fields->request_id}; |
| 209 | const size_t caps[] = {sizeof(fields->issuer), sizeof(fields->app), sizeof(fields->action), sizeof(fields->key_id), sizeof(fields->request_id)}; |
| 210 | for (int i = 0; i < 5; ++i) { |
| 211 | const size_t n = strnlen(strings[i], caps[i]); |
| 212 | if (n == caps[i] || n > 255) { set_status(diag, CKEY_INVALID_ARGUMENT, "a text field is not terminated"); return CKEY_INVALID_ARGUMENT; } |
| 213 | b.push_back(static_cast<uint8_t>(n)); |
| 214 | b.insert(b.end(), strings[i], strings[i] + n); |
| 215 | } |
| 216 | for (int i = 7; i >= 0; --i) b.push_back(static_cast<uint8_t>(fields->issued_at >> (8 * i))); |
| 217 | for (int i = 7; i >= 0; --i) b.push_back(static_cast<uint8_t>(fields->expires_at >> (8 * i))); |
| 218 | if (fields->params_len > sizeof(fields->params)) { set_status(diag, CKEY_INVALID_ARGUMENT, "params longer than 1024 bytes"); return CKEY_INVALID_ARGUMENT; } |
| 219 | b.push_back(static_cast<uint8_t>(fields->params_len >> 8)); |
| 220 | b.push_back(static_cast<uint8_t>(fields->params_len)); |
| 221 | b.insert(b.end(), fields->params, fields->params + fields->params_len); |
| 222 | // Validate with the same parser readers use, so a signed envelope always parses. |
| 223 | { |
| 224 | std::vector<uint8_t> probe = b; |
| 225 | probe.resize(probe.size() + 64); |
| 226 | ckey_safe_action check; |
| 227 | const char *why = "malformed"; |
| 228 | if (!sa_parse_bytes(probe, &check, &why)) { set_status(diag, CKEY_INVALID_ARGUMENT, (std::string("invalid Safe Action: ") + why).c_str()); return CKEY_INVALID_ARGUMENT; } |
| 229 | } |
| 230 | std::vector<uint8_t> message(kSafeActionContext, kSafeActionContext + sizeof(kSafeActionContext)); |
| 231 | message.insert(message.end(), b.begin(), b.end()); |
| 232 | uint8_t sig[64]; |
| 233 | ckey_crypto::ed25519_sign(sig, message.data(), message.size(), seed); |
| 234 | b.insert(b.end(), sig, sig + 64); |
| 235 | std::string text(kSafeActionPrefix); |
| 236 | b64url_encode(b, text); |
| 237 | if (out_len) *out_len = text.size(); |
| 238 | if (!out || out_capacity < text.size() + 1) { set_status(diag, CKEY_INSUFFICIENT_BUFFER, "output buffer too small"); return CKEY_INSUFFICIENT_BUFFER; } |
| 239 | std::memcpy(out, text.data(), text.size()); |
| 240 | out[text.size()] = '\0'; |
| 241 | set_status(diag, CKEY_OK, "signed"); |
| 242 | return CKEY_OK; |
| 243 | } |
| 244 | |
| 245 | void ckey_ed25519_public_key(const uint8_t seed[32], uint8_t out_public_key[32]) { |
| 246 | if (seed && out_public_key) ckey_crypto::ed25519_public_key(out_public_key, seed); |
| 247 | } |
| 248 | |
| 249 | } // extern "C" |