TestMain.vala 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Mapping;
  4. using InvercargillJson;
  5. using Inversion;
  6. using Astralis;
  7. using Statum;
  8. namespace Statum.Tests {
  9. int failures = 0;
  10. int passes = 0;
  11. void check(bool condition, string label) {
  12. if (condition) {
  13. passes++;
  14. print("PASS %s\n", label);
  15. } else {
  16. failures++;
  17. print("FAIL %s\n", label);
  18. }
  19. }
  20. /**
  21. * Scalar model in the style of Percipio's login private data: the field
  22. * types that previously crashed (int64, bool) or mis-serialised (int) the
  23. * encryption path.
  24. */
  25. public class LoginPrivate : Object {
  26. public int64 user_id { get; set; }
  27. public bool is_admin { get; set; }
  28. public int attempts { get; set; }
  29. public double score { get; set; }
  30. public string username { get; set; }
  31. public long session_no { get; set; }
  32. }
  33. /** List-bearing model: a `string[]` plus a collection of a registered type. */
  34. public class ArrayModel : Object {
  35. public string[] tags { get; set; }
  36. public Series<Model.ActionDto> refs { get; set; }
  37. }
  38. /** Typed action WITHOUT a `private_type` override — demonstrates S-8 erosion. */
  39. public class ErodingAction : TypedStatumAction<LoginPrivate> {
  40. public Type exposed_private_type() {
  41. return private_type;
  42. }
  43. public LoginPrivate? exposed_request_private() {
  44. return request_private;
  45. }
  46. public void feed(Properties props) {
  47. request = new StatumRequest() { action_private = props };
  48. }
  49. public override async DirectiveBuilder handle() throws GLib.Error {
  50. return directives();
  51. }
  52. }
  53. /** Typed action WITH the `private_type` override — the supported pattern. */
  54. public class FixedAction : TypedStatumAction<LoginPrivate> {
  55. protected override Type private_type { get { return typeof(LoginPrivate); } }
  56. public Type exposed_private_type() {
  57. return private_type;
  58. }
  59. public LoginPrivate? exposed_request_private() {
  60. return request_private;
  61. }
  62. public void feed(Properties props) {
  63. request = new StatumRequest() { action_private = props };
  64. }
  65. public override async DirectiveBuilder handle() throws GLib.Error {
  66. return directives();
  67. }
  68. }
  69. LoginPrivate sample_private() {
  70. return new LoginPrivate() {
  71. user_id = (int64)9007199254740993,
  72. is_admin = true,
  73. attempts = 3,
  74. score = 0.5,
  75. username = "bbarrow",
  76. session_no = (long)123456789012345
  77. };
  78. }
  79. void assert_private_matches(LoginPrivate expected, LoginPrivate actual, string label) {
  80. check(actual.user_id == expected.user_id, label + ": user_id int64 round-trip");
  81. check(actual.is_admin == expected.is_admin, label + ": is_admin bool round-trip");
  82. check(actual.attempts == expected.attempts, label + ": attempts int round-trip");
  83. check(actual.score == expected.score, label + ": score double round-trip");
  84. check(actual.username == expected.username, label + ": username string round-trip");
  85. check(actual.session_no == expected.session_no, label + ": session_no long round-trip");
  86. }
  87. void test_scalar_public_path() throws GLib.Error {
  88. var model = sample_private();
  89. var props = GObjectMapping.to_properties(model);
  90. var wire = new JsonElement.from_properties(props).stringify();
  91. check(wire.contains("\"user_id\":9007199254740993"), "public path: int64 serialises as an unquoted JSON number");
  92. check(wire.contains("\"is_admin\":true"), "public path: bool serialises as an unquoted JSON boolean");
  93. check(wire.contains("\"attempts\":3"), "public path: int serialises as an unquoted JSON number, not a string");
  94. check(wire.contains("\"score\":0.5"), "public path: double serialises as an unquoted JSON number");
  95. var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate),
  96. new JsonElement.from_string(wire).as<Properties>());
  97. assert_private_matches(model, back, "public path");
  98. }
  99. void test_scalar_encryption_path(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  100. var model = sample_private();
  101. var blob = encryption.author_properties("statum-tests", GObjectMapping.to_properties(model));
  102. var props = encryption.read_properties("statum-tests", blob);
  103. var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate), props);
  104. assert_private_matches(model, back, "encryption path");
  105. }
  106. void test_arrays(Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  107. var first = new Model.ActionDto() { uri = "/_statum/action/aaaa", method = "POST" };
  108. var second = new Model.ActionDto() { uri = "/_statum/action/bbbb", method = "DELETE" };
  109. var refs = new Series<Model.ActionDto>();
  110. refs.add(first);
  111. refs.add(second);
  112. var model = new ArrayModel() { tags = new string[] { "alpha", "beta", "gamma" }, refs = refs };
  113. var props = GObjectMapping.to_properties(model);
  114. var wire = new JsonElement.from_properties(props).stringify();
  115. check(wire.contains("\"tags\":[\"alpha\",\"beta\",\"gamma\"]"), "arrays: string[] serialises as a JSON array");
  116. check(wire.contains("\"uri\":\"/_statum/action/bbbb\"")
  117. && wire.contains("\"method\":\"DELETE\""), "arrays: registered-Object collection serialises as an array of nested objects");
  118. var parsed = new JsonElement.from_string(wire).as<Properties>();
  119. var array_model = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel), parsed);
  120. check(array_model.tags.length == 3 && array_model.tags[0] == "alpha"
  121. && array_model.tags[1] == "beta" && array_model.tags[2] == "gamma", "arrays: string[] read-back from JSON array");
  122. var blob = encryption.author_properties("statum-tests", props);
  123. var decrypted = (ArrayModel) GObjectMapping.from_properties(typeof(ArrayModel),
  124. encryption.read_properties("statum-tests", blob));
  125. check(decrypted.tags.length == 3 && decrypted.tags[2] == "gamma", "arrays: string[] round-trips through the encryption path");
  126. }
  127. void test_typed_action_private(Inversion.Scope scope, Statum.Cryptography.EncryptionProvider encryption) throws GLib.Error {
  128. var eroding = (ErodingAction) scope.resolve_type(typeof(ErodingAction));
  129. check(eroding.exposed_private_type() == typeof(void), "S-8 repro: typeof(TPrivate) erodes to void for a reflectively-instantiated action");
  130. eroding.feed(new PropertyDictionary());
  131. check(eroding.exposed_request_private() == null, "S-8 repro: un-overridden request_private yields null without crashing");
  132. var fixed_action = (FixedAction) scope.resolve_type(typeof(FixedAction));
  133. check(fixed_action.exposed_private_type() == typeof(LoginPrivate), "S-8 fix: private_type override captures the GType from a foreign namespace");
  134. var model = sample_private();
  135. var blob = encryption.author_properties(fixed_action.get_type().name(), GObjectMapping.to_properties(model));
  136. fixed_action.feed(encryption.read_properties(fixed_action.get_type().name(), blob));
  137. var private_data = fixed_action.exposed_request_private();
  138. check(private_data != null, "S-8 fix: request_private materialises from a sealed blob");
  139. if (private_data != null) {
  140. assert_private_matches(model, (!)private_data, "S-8 fix");
  141. }
  142. }
  143. /**
  144. * SA-4: restore_slot must enforce the snapshot's invalid_after and must
  145. * not refresh last_touched, so replayed frames cannot pin slots forever
  146. * and the cleanup sweep still expires restored slots.
  147. */
  148. void test_restore_slot_expiry(StateService states, Statum.Cryptography.SigningProvider signing) throws GLib.Error {
  149. // A live frame authored now: restorable, keeps its own as_at as
  150. // last_touched, and still expirable by cleanup.
  151. var slot = states.new_slot(Scope.SESSION, new State() {
  152. type_name = "test-sa4",
  153. public_data = new PropertyDictionary(),
  154. private_data = new PropertyDictionary()
  155. });
  156. var live_frame = sign_snapshot(signing, slot.id, "test-sa4", new DateTime.now_utc().add_hours(6));
  157. var before_restore = new DateTime.now_utc();
  158. // Evict the server-side copy first so restore_slot has an empty cache
  159. // to restore into (a non-empty slot makes restore a no-op).
  160. states.clear_slot.begin(slot.id);
  161. check(states.get_slot(slot.id) == null, "SA-4: slot evicted before first restore");
  162. var restored = states.restore_slot(live_frame);
  163. check(restored, "SA-4: a frame inside its invalid_after window restores");
  164. var cached = states.get_slot(slot.id);
  165. check(cached != null, "SA-4: the restored slot is cached");
  166. if (cached != null) {
  167. var touched = ((!)cached).current_state.last_touched;
  168. check(touched.compare(before_restore) <= 0,
  169. "SA-4: restore does not refresh last_touched (the clock is not 'now')");
  170. }
  171. // The same slot re-restored after eviction must still expire through
  172. // cleanup, because last_touched came from the snapshot, not 'now'.
  173. states.clear_slot.begin(slot.id); // remove from cache (async push is a no-op)
  174. check(states.get_slot(slot.id) == null, "SA-4: slot evicted before replay");
  175. // Re-sign a frame with an as_at in the past but still valid.
  176. var old_touch = new DateTime.now_utc().add_days(-6);
  177. var stale_frame = sign_snapshot(signing, slot.id, "test-sa4-old", new DateTime.now_utc().add_hours(1), old_touch);
  178. check(states.restore_slot(stale_frame), "SA-4: stale-but-valid frame restores");
  179. states.cleanup();
  180. check(states.get_slot(slot.id) != null, "SA-4: restored slot within EXPIRY_SECONDS survives cleanup");
  181. // A frame whose as_at predates the 7-day expiry is gone after cleanup.
  182. var ancient_touch = new DateTime.now_utc().add_days(-9);
  183. var ancient_frame = sign_snapshot(signing, slot.id + "-ancient", "test-sa4-ancient",
  184. new DateTime.now_utc().add_hours(1), ancient_touch);
  185. check(states.restore_slot(ancient_frame), "SA-4: ancient-but-valid frame restores (once)");
  186. states.cleanup();
  187. check(states.get_slot(slot.id + "-ancient") == null,
  188. "SA-4: cleanup expires a restored slot (restore no longer resets last_touched to now)");
  189. // A frame past its invalid_after is rejected outright.
  190. var expired_frame = sign_snapshot(signing, slot.id + "-expired", "test-sa4-expired",
  191. new DateTime.now_utc().add_seconds(-5));
  192. bool threw = false;
  193. try {
  194. states.restore_slot(expired_frame);
  195. } catch (StateError e) {
  196. threw = true;
  197. } catch (GLib.Error e) {
  198. threw = false;
  199. }
  200. check(threw, "SA-4: restore_slot rejects a frame past invalid_after with FRAME_EXPIRED");
  201. check(states.get_slot(slot.id + "-expired") == null,
  202. "SA-4: a rejected frame leaves no slot cached");
  203. // SA-4 bonus: a rejected replay also drops any slot already cached
  204. // under the same key, so the stale entry cannot linger.
  205. var cached_key = slot.id + "-drop";
  206. check(states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
  207. new DateTime.now_utc().add_hours(1))), "SA-4: slot cached for the drop test");
  208. check(states.get_slot(cached_key) != null, "SA-4: slot present before expired replay");
  209. try {
  210. states.restore_slot(sign_snapshot(signing, cached_key, "test-sa4-drop",
  211. new DateTime.now_utc().add_seconds(-5)));
  212. } catch (StateError.FRAME_EXPIRED e) {
  213. // expected
  214. }
  215. check(states.get_slot(cached_key) == null,
  216. "SA-4: an expired replay of the same key invalidates the cached slot");
  217. }
  218. /** Signs a snapshot for `key` valid until `invalid_after`, authored `as_at`. */
  219. Model.FrameDto sign_snapshot(Statum.Cryptography.SigningProvider signing, string key, string type,
  220. DateTime invalid_after, DateTime? as_at = null) throws GLib.Error {
  221. var dto = new Model.SnapshotDto() {
  222. as_at = as_at ?? new DateTime.now_utc(),
  223. slot = new Model.SlotDto() {
  224. key = key,
  225. scope = Scope.SESSION,
  226. slot_type = type
  227. },
  228. transmit_after = new DateTime.now_utc().add_seconds(60),
  229. invalid_after = invalid_after,
  230. @public = new PropertyDictionary()
  231. };
  232. var json = new JsonElement.from_properties(Model.SnapshotDto.get_mapper().map_from(dto)).stringify();
  233. return signing.author_frame(json);
  234. }
  235. /** SA-10: verify_frame must fail closed on a mismatched signer claim. */
  236. void test_verify_frame_signer(Statum.Cryptography.SigningProvider signing,
  237. Statum.Cryptography.SigningProvider other) throws GLib.Error {
  238. var frame = sign_snapshot(signing, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
  239. signing.verify_frame(frame);
  240. check(true, "SA-10: a frame signed and claimed by this provider verifies");
  241. // Same content and signature, forged signer claim.
  242. var forged = new Model.FrameDto() {
  243. content = frame.content,
  244. signer = other.signer_id,
  245. signature = frame.signature
  246. };
  247. bool threw = false;
  248. try {
  249. signing.verify_frame(forged);
  250. } catch (Statum.Cryptography.SigningError e) {
  251. threw = true;
  252. }
  253. check(threw, "SA-10: verify_frame rejects a frame whose signer != signer_id");
  254. // A frame actually signed by another provider is rejected too (its
  255. // signature does not verify against this key).
  256. var foreign = sign_snapshot(other, "sa10-key", "test-sa10", new DateTime.now_utc().add_hours(1));
  257. threw = false;
  258. try {
  259. signing.verify_frame(foreign);
  260. } catch (Statum.Cryptography.SigningError e) {
  261. threw = true;
  262. }
  263. check(threw, "SA-10: verify_frame rejects a frame signed by a different key");
  264. }
  265. // -----------------------------------------------------------------
  266. // Locksmith port (goal 44): wire compatibility and restart survival.
  267. // Key material is generated through Locksmith directly — mirroring
  268. // `statum-genkeys` without shelling out to it.
  269. // -----------------------------------------------------------------
  270. /** Raw key bytes in the shape `web-config.json` carries. */
  271. public class KeyMaterial : Object {
  272. public uint8[] signing_sk = new uint8[0];
  273. public uint8[] signing_pk = new uint8[0];
  274. public uint8[] enc_signing_sk = new uint8[0];
  275. public uint8[] enc_signing_pk = new uint8[0];
  276. public uint8[] sealing_sk = new uint8[0];
  277. public uint8[] sealing_pk = new uint8[0];
  278. }
  279. KeyMaterial generate_key_material() {
  280. var material = new KeyMaterial();
  281. var signing = Locksmith.Signing.KeyPair.generate();
  282. material.signing_sk = signing.to_secret_key().to_array();
  283. material.signing_pk = signing.public_key.key.to_array();
  284. var enc_signing = Locksmith.Signing.KeyPair.generate();
  285. material.enc_signing_sk = enc_signing.to_secret_key().to_array();
  286. material.enc_signing_pk = enc_signing.public_key.key.to_array();
  287. var sealing = Locksmith.Sealing.KeyPair.generate();
  288. material.sealing_sk = sealing.to_secret_key().to_array();
  289. material.sealing_pk = sealing.public_key.key.to_array();
  290. return material;
  291. }
  292. Statum.Cryptography.SigningProvider signing_from_keys(KeyMaterial material) throws Locksmith.Error {
  293. return new Statum.Cryptography.SigningProvider.with_keys(material.signing_sk, material.signing_pk);
  294. }
  295. Statum.Cryptography.EncryptionProvider encryption_from_keys(KeyMaterial material) throws Locksmith.Error {
  296. return new Statum.Cryptography.EncryptionProvider.with_keys(
  297. material.enc_signing_sk, material.enc_signing_pk,
  298. material.sealing_sk, material.sealing_pk);
  299. }
  300. /**
  301. * Restart survival: providers rebuilt from the same raw key bytes (the
  302. * `web-config.json` static-key path) verify frames and unseal private
  303. * blobs that were authored before the "restart".
  304. */
  305. void test_locksmith_restart_survival() throws GLib.Error {
  306. var material = generate_key_material();
  307. Statum.Cryptography.SigningProvider first_signing = null;
  308. Statum.Cryptography.EncryptionProvider first_encryption = null;
  309. try {
  310. first_signing = signing_from_keys(material);
  311. first_encryption = encryption_from_keys(material);
  312. } catch (Locksmith.Error e) {
  313. check(false, "restart: with_keys accepts freshly generated Locksmith key material (" + e.message + ")");
  314. return;
  315. }
  316. check(true, "restart: with_keys accepts freshly generated Locksmith key material");
  317. // Author a frame and a sealed private blob with the "before" pair.
  318. var frame = first_signing.author_frame("{\"slot\":\"restart-survival\"}");
  319. var model = sample_private();
  320. var blob = first_encryption.author_properties("statum-tests", GObjectMapping.to_properties(model));
  321. // Fresh provider instances from the same raw bytes model a restart.
  322. Statum.Cryptography.SigningProvider second_signing;
  323. Statum.Cryptography.EncryptionProvider second_encryption;
  324. try {
  325. second_signing = signing_from_keys(material);
  326. second_encryption = encryption_from_keys(material);
  327. } catch (Locksmith.Error e) {
  328. check(false, "restart: with_keys rebuilds providers from the same raw key bytes (" + e.message + ")");
  329. return;
  330. }
  331. check(true, "restart: with_keys rebuilds providers from the same raw key bytes");
  332. second_signing.verify_frame(frame);
  333. check(true, "restart: a frame authored before the restart verifies through a rebuilt provider");
  334. var back = (LoginPrivate) GObjectMapping.from_properties(typeof(LoginPrivate),
  335. second_encryption.read_properties("statum-tests", blob));
  336. assert_private_matches(model, back, "restart");
  337. check(first_signing.signer_id == second_signing.signer_id,
  338. "restart: signer_id is identical across provider instances");
  339. check(Base64.decode(frame.signature).length == 64,
  340. "restart: frame signatures are 64-byte detached Ed25519 signatures");
  341. }
  342. /**
  343. * Wire stability of `signer_id`: Locksmith's `to_base64` and GLib's
  344. * `Base64.encode` agree on the raw 32-byte public key, so frames already
  345. * held by clients (localStorage) keep verifying and keep naming the same
  346. * signer after the Locksmith port.
  347. */
  348. void test_locksmith_signer_id_wire_stability() throws GLib.Error {
  349. var ephemeral = new Statum.Cryptography.SigningProvider();
  350. var raw = ephemeral.get_public_key();
  351. check(raw.length == 32, "wire: get_public_key returns a raw 32-byte Ed25519 public key");
  352. check(ephemeral.signer_id == Base64.encode(raw),
  353. "wire: signer_id is GLib Base64.encode of the raw public key (ephemeral provider)");
  354. var material = generate_key_material();
  355. try {
  356. var configured = signing_from_keys(material);
  357. check(configured.signer_id == Base64.encode(configured.get_public_key()),
  358. "wire: signer_id is GLib Base64.encode of the raw public key (configured provider)");
  359. check(configured.signer_id == Base64.encode(material.signing_pk),
  360. "wire: a configured provider's signer_id matches its configured public key bytes");
  361. } catch (Locksmith.Error e) {
  362. check(false, "wire: configured provider constructs (" + e.message + ")");
  363. }
  364. }
  365. /**
  366. * Foreign and malformed verification keys verify nothing and crash
  367. * nothing (no GLib criticals).
  368. */
  369. void test_locksmith_foreign_and_malformed_keys() throws GLib.Error {
  370. var material = generate_key_material();
  371. var provider = signing_from_keys(material);
  372. var frame = provider.author_frame("{\"slot\":\"foreign\"}");
  373. check(provider.verify_with_key(frame.content, frame.signature, material.signing_pk),
  374. "foreign: the provider's own public key verifies its signature");
  375. var foreign = Locksmith.Signing.KeyPair.generate();
  376. check(!provider.verify_with_key(frame.content, frame.signature, foreign.public_key.key.to_array()),
  377. "foreign: an unrelated third key's public key verifies nothing");
  378. var malformed = new uint8[31];
  379. check(!provider.verify_with_key(frame.content, frame.signature, malformed),
  380. "malformed: a wrong-length (31-byte) verification key verifies nothing");
  381. }
  382. /**
  383. * `with_keys` pk-mismatch policy: the public key DERIVED from the secret
  384. * wins. A supplied config pk that disagrees with the secret is a config
  385. * error (loud warning at construction — not captured here); the derived
  386. * key is the one that signs, names the signer and verifies.
  387. */
  388. void test_locksmith_pk_mismatch_policy() throws GLib.Error {
  389. var material = generate_key_material();
  390. var wrong_pk = Locksmith.Signing.KeyPair.generate().public_key.key.to_array();
  391. var provider = new Statum.Cryptography.SigningProvider.with_keys(material.signing_sk, wrong_pk);
  392. check(provider.signer_id == Base64.encode(material.signing_pk),
  393. "mismatch: signer_id is the pk derived from the secret, not the supplied one");
  394. check(provider.signer_id != Base64.encode(wrong_pk),
  395. "mismatch: the supplied wrong public key is not the signer_id");
  396. var frame = provider.author_frame("{\"slot\":\"mismatch\"}");
  397. check(provider.verify_with_key(frame.content, frame.signature, material.signing_pk),
  398. "mismatch: a signature by this provider verifies against the derived pk");
  399. check(!provider.verify_with_key(frame.content, frame.signature, wrong_pk),
  400. "mismatch: the same signature fails against the supplied wrong pk");
  401. }
  402. /**
  403. * Error semantics survived the Locksmith port: namespace binding rejects
  404. * cross-namespace reads, garbage fails to decrypt, and a tampered blob
  405. * never reads back — all as `EncryptionError`, never a crash.
  406. */
  407. void test_locksmith_error_semantics() throws GLib.Error {
  408. var encryption = new Statum.Cryptography.EncryptionProvider();
  409. var blob = encryption.author_string("statum-a", "{\"n\":1}");
  410. bool mismatched = false;
  411. try {
  412. encryption.read_string("statum-b", blob);
  413. } catch (Statum.Cryptography.EncryptionError.NAMESPACE_MISMATCH e) {
  414. mismatched = true;
  415. }
  416. check(mismatched, "errors: a blob authored under namespace \"a\" and read under \"b\" throws NAMESPACE_MISMATCH");
  417. var garbage = Base64.encode(new uint8[] { 1, 2, 3, 4, 5 });
  418. bool undecryptable = false;
  419. try {
  420. encryption.read_string("statum-a", garbage);
  421. } catch (Statum.Cryptography.EncryptionError.DECRYPTION_FAILED e) {
  422. undecryptable = true;
  423. }
  424. check(undecryptable, "errors: base64-encoded garbage throws DECRYPTION_FAILED");
  425. var bytes = Base64.decode(blob);
  426. bytes[bytes.length - 1] = bytes[bytes.length - 1] ^ 0xff;
  427. bool tampering_threw = false;
  428. string detail = "read back without throwing";
  429. try {
  430. encryption.read_string("statum-a", Base64.encode(bytes));
  431. } catch (Statum.Cryptography.EncryptionError e) {
  432. tampering_threw = true;
  433. detail = e.message;
  434. }
  435. check(tampering_threw, "errors: a byte-flipped blob throws EncryptionError, not a crash (" + detail + ")");
  436. }
  437. /** SA-15: global and per-IP channel connection caps. */
  438. void test_connection_caps() {
  439. var tracker = new ConnectionTracker(4, 2);
  440. check(tracker.can_accept("10.0.0.1"), "SA-15: first connection from an IP is accepted");
  441. tracker.acquired("10.0.0.1");
  442. check(tracker.can_accept("10.0.0.1"), "SA-15: second connection from the same IP is accepted");
  443. tracker.acquired("10.0.0.1");
  444. check(!tracker.can_accept("10.0.0.1"), "SA-15: third connection from the same IP is refused (per-IP cap)");
  445. check(tracker.can_accept("10.0.0.2"), "SA-15: another IP is unaffected by the per-IP cap");
  446. tracker.released("10.0.0.1");
  447. check(tracker.can_accept("10.0.0.1"), "SA-15: releasing a connection frees its per-IP slot");
  448. tracker.acquired("10.0.0.2");
  449. tracker.acquired("10.0.0.3");
  450. // total: 1 + 1 + 1 = 3 of 4 — the next is the last.
  451. check(tracker.can_accept("10.0.0.4"), "SA-15: connection accepted up to the global cap");
  452. tracker.acquired("10.0.0.4");
  453. check(!tracker.can_accept("10.0.0.4"), "SA-15: connections beyond the global cap are refused");
  454. check(!tracker.can_accept("192.168.0.1"), "SA-15: the global cap refuses new IPs too");
  455. check(tracker.open_total() == 4, "SA-15: the global count tracks acquisitions");
  456. tracker.released("10.0.0.2");
  457. check(tracker.can_accept("192.168.0.1"), "SA-15: a release frees the global slot");
  458. // Unknown clients count globally but cannot trip the per-IP cap.
  459. var anon = new ConnectionTracker(1, 2);
  460. check(anon.can_accept(null), "SA-15: unknown client is accepted when under the global cap");
  461. anon.acquired(null);
  462. check(!anon.can_accept(null), "SA-15: unknown clients still respect the global cap");
  463. check(anon.open_for(null) == 0, "SA-15: unknown client is not counted per-IP");
  464. }
  465. /** SA-15: subscriptions per channel are bounded. */
  466. void test_subscription_cap(ChannelEndpoint channel) {
  467. // subscribe() only acts on channels with an open connection; without
  468. // one it must simply no-op (returning false) rather than crash.
  469. bool recorded = channel.subscribe("nonexistent-channel", "some-key");
  470. check(!recorded, "SA-15: subscribing to a channel with no connection is a no-op");
  471. }
  472. /** SA-6: the authorize hooks default open and honour installed guards. */
  473. void test_authorization_policy() {
  474. var policy = new AuthorizationPolicy();
  475. check(policy.authorize_entrypoint(null) == null, "SA-6: entrypoint is open when no guard is installed");
  476. check(policy.authorize_channel(null) == null, "SA-6: channel is open when no guard is installed");
  477. check(policy.authorize_channel_subscriptions(null) == null, "SA-6: channel subscriptions are open when no guard is installed");
  478. var denied = new HttpStringResult("login required", StatusCode.UNAUTHORIZED);
  479. policy.guard_channel(ctx => denied);
  480. var decision = policy.authorize_channel(null);
  481. check(decision != null && ((!)decision).status == StatusCode.UNAUTHORIZED,
  482. "SA-6: an installed channel guard short-circuits with its result");
  483. check(policy.authorize_entrypoint(null) == null, "SA-6: the channel guard does not leak into the entrypoint");
  484. policy.guard_entrypoint(ctx => null);
  485. check(policy.authorize_entrypoint(null) == null, "SA-6: a guard returning null allows the request");
  486. }
  487. void main() {
  488. GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
  489. var container = new Container();
  490. container.register_singleton<Statum.Cryptography.SigningProvider>();
  491. container.register_singleton<Statum.Cryptography.EncryptionProvider>();
  492. container.register_singleton<StateService>();
  493. container.register_singleton<HeldSlotResolver>();
  494. container.register_singleton<ChannelEndpoint>().as<ChannelService>();
  495. container.register_singleton<ActionRegistry>();
  496. container.register_singleton<TopicRegistry>();
  497. container.register_scoped<ErodingAction>();
  498. container.register_scoped<FixedAction>();
  499. var scope = container.create_transient_scope();
  500. var encryption = (Statum.Cryptography.EncryptionProvider) scope.resolve_type(typeof(Statum.Cryptography.EncryptionProvider));
  501. var signing = (Statum.Cryptography.SigningProvider) scope.resolve_type(typeof(Statum.Cryptography.SigningProvider));
  502. var states = (StateService) scope.resolve_type(typeof(StateService));
  503. var channel = (ChannelEndpoint) scope.resolve_type(typeof(ChannelEndpoint));
  504. try {
  505. test_scalar_public_path();
  506. } catch (GLib.Error e) {
  507. failures++;
  508. print("FAIL scalar public path threw: %s\n", e.message);
  509. }
  510. try {
  511. test_scalar_encryption_path(encryption);
  512. } catch (GLib.Error e) {
  513. failures++;
  514. print("FAIL scalar encryption path threw: %s\n", e.message);
  515. }
  516. try {
  517. test_arrays(encryption);
  518. } catch (GLib.Error e) {
  519. failures++;
  520. print("FAIL arrays threw: %s\n", e.message);
  521. }
  522. try {
  523. test_typed_action_private(scope, encryption);
  524. } catch (GLib.Error e) {
  525. failures++;
  526. print("FAIL typed action private threw: %s\n", e.message);
  527. }
  528. try {
  529. test_restore_slot_expiry(states, signing);
  530. } catch (GLib.Error e) {
  531. failures++;
  532. print("FAIL restore_slot expiry threw: %s\n", e.message);
  533. }
  534. try {
  535. test_verify_frame_signer(signing, new Statum.Cryptography.SigningProvider());
  536. } catch (GLib.Error e) {
  537. failures++;
  538. print("FAIL verify_frame signer threw: %s\n", e.message);
  539. }
  540. try {
  541. test_locksmith_restart_survival();
  542. } catch (GLib.Error e) {
  543. failures++;
  544. print("FAIL locksmith restart survival threw: %s\n", e.message);
  545. }
  546. try {
  547. test_locksmith_signer_id_wire_stability();
  548. } catch (GLib.Error e) {
  549. failures++;
  550. print("FAIL locksmith signer_id wire stability threw: %s\n", e.message);
  551. }
  552. try {
  553. test_locksmith_foreign_and_malformed_keys();
  554. } catch (GLib.Error e) {
  555. failures++;
  556. print("FAIL locksmith foreign/malformed keys threw: %s\n", e.message);
  557. }
  558. try {
  559. test_locksmith_pk_mismatch_policy();
  560. } catch (GLib.Error e) {
  561. failures++;
  562. print("FAIL locksmith pk-mismatch policy threw: %s\n", e.message);
  563. }
  564. try {
  565. test_locksmith_error_semantics();
  566. } catch (GLib.Error e) {
  567. failures++;
  568. print("FAIL locksmith error semantics threw: %s\n", e.message);
  569. }
  570. try {
  571. test_connection_caps();
  572. } catch (GLib.Error e) {
  573. failures++;
  574. print("FAIL connection caps threw: %s\n", e.message);
  575. }
  576. try {
  577. test_subscription_cap(channel);
  578. } catch (GLib.Error e) {
  579. failures++;
  580. print("FAIL subscription cap threw: %s\n", e.message);
  581. }
  582. try {
  583. test_authorization_policy();
  584. } catch (GLib.Error e) {
  585. failures++;
  586. print("FAIL authorization policy threw: %s\n", e.message);
  587. }
  588. print("---- %d passed, %d failed ----\n", passes, failures);
  589. if (failures > 0) {
  590. Process.exit(1);
  591. }
  592. }
  593. }