primitives.test.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. const fs = require('fs');
  2. const os = require('os');
  3. const path = require('path');
  4. const { eq, ok, notOk, deepEq } = require('../../helpers/assert');
  5. const tmpRoot = path.join(os.tmpdir(), 'oasis-crypto-tests');
  6. const fresh = () => {
  7. const dir = path.join(tmpRoot, 'd-' + Date.now() + '-' + Math.random().toString(36).slice(2));
  8. fs.mkdirSync(dir, { recursive: true });
  9. return dir;
  10. };
  11. const tribeCryptoFactory = require('../../../src/models/crypto');
  12. describe('crypto: keyring', (t) => {
  13. t('generateTribeKey returns 64 hex chars (32 bytes)', () => {
  14. const tc = tribeCryptoFactory(fresh());
  15. const k = tc.generateTribeKey();
  16. eq(k.length, 64);
  17. ok(/^[0-9a-f]+$/.test(k));
  18. });
  19. t('setKey persists and getKey reads back', () => {
  20. const dir = fresh();
  21. const tc = tribeCryptoFactory(dir);
  22. tc.setKey('%test.sha256', 'a'.repeat(64), 1);
  23. eq(tc.getKey('%test.sha256'), 'a'.repeat(64));
  24. eq(tc.getGen('%test.sha256'), 1);
  25. });
  26. t('addNewKey for multi-gen', () => {
  27. const tc = tribeCryptoFactory(fresh());
  28. tc.setKey('%x.sha256', 'k1', 1);
  29. eq(tc.addNewKey('%x.sha256', 'k2'), 2);
  30. deepEq(tc.getKeys('%x.sha256'), ['k2', 'k1']);
  31. });
  32. t('mergeKeys deduplicates', () => {
  33. const tc = tribeCryptoFactory(fresh());
  34. tc.setKey('%x.sha256', 'a', 1);
  35. tc.mergeKeys('%x.sha256', ['a', 'b', 'c'], 3);
  36. deepEq(tc.getKeys('%x.sha256'), ['a', 'b', 'c']);
  37. });
  38. t('keyring file is mode 0600', () => {
  39. const dir = fresh();
  40. const tc = tribeCryptoFactory(dir);
  41. tc.setKey('%x.sha256', 'k', 1);
  42. const stat = fs.statSync(path.join(dir, 'keys', 'tribes-keys.json'));
  43. eq(stat.mode & 0o777, 0o600);
  44. });
  45. t('namespaces produce separate keyring files', () => {
  46. const dir = fresh();
  47. const tribes = tribeCryptoFactory(dir, 'tribes');
  48. const chats = tribeCryptoFactory(dir, 'chats');
  49. tribes.setKey('%t1.sha256', 'a'.repeat(64), 1);
  50. chats.setKey('%c1.sha256', 'b'.repeat(64), 1);
  51. ok(fs.existsSync(path.join(dir, 'keys', 'tribes-keys.json')));
  52. ok(fs.existsSync(path.join(dir, 'keys', 'chats-keys.json')));
  53. eq(tribes.getKey('%t1.sha256'), 'a'.repeat(64));
  54. eq(chats.getKey('%c1.sha256'), 'b'.repeat(64));
  55. eq(tribes.getKey('%c1.sha256'), null, 'tribes keyring does not see chats keys');
  56. eq(chats.getKey('%t1.sha256'), null, 'chats keyring does not see tribes keys');
  57. });
  58. t('legacy ~/.ssb/tribe-keys.json auto-migrates to keys/tribes-keys.json', () => {
  59. const dir = fresh();
  60. const legacyPath = path.join(dir, 'tribe-keys.json');
  61. fs.writeFileSync(legacyPath, JSON.stringify({ '%legacy.sha256': { keys: ['ff'.repeat(32)], gen: 1 } }), { mode: 0o600 });
  62. const tc = tribeCryptoFactory(dir, 'tribes');
  63. notOk(fs.existsSync(legacyPath), 'legacy file removed');
  64. ok(fs.existsSync(path.join(dir, 'keys', 'tribes-keys.json')), 'new file present');
  65. eq(tc.getKey('%legacy.sha256'), 'ff'.repeat(32), 'data preserved');
  66. });
  67. t('per-module keyrings: tribes, chats, pads, maps, calendars produce 5 files', () => {
  68. const dir = fresh();
  69. for (const ns of ['tribes', 'chats', 'pads', 'maps', 'calendars']) {
  70. const inst = tribeCryptoFactory(dir, ns);
  71. inst.setKey(`%${ns}.sha256`, ns.charAt(0).repeat(64), 1);
  72. }
  73. for (const ns of ['tribes', 'chats', 'pads', 'maps', 'calendars']) {
  74. ok(fs.existsSync(path.join(dir, 'keys', `${ns}-keys.json`)), `${ns}-keys.json exists`);
  75. }
  76. });
  77. });
  78. describe('crypto: fingerprint', (t) => {
  79. t('deterministic, 32 hex', () => {
  80. const tc = tribeCryptoFactory(fresh());
  81. const k = '1234567890abcdef'.repeat(4);
  82. const fp = tc.fingerprint(k);
  83. eq(fp.length, 32);
  84. eq(tc.fingerprint(k), fp);
  85. });
  86. t('different keys produce different fingerprints', () => {
  87. const tc = tribeCryptoFactory(fresh());
  88. notOk(tc.fingerprint(tc.generateTribeKey()) === tc.fingerprint(tc.generateTribeKey()));
  89. });
  90. t('buildFingerprintIndex maps fp to all keys (multi-gen)', () => {
  91. const tc = tribeCryptoFactory(fresh());
  92. tc.setKey('%a.sha256', 'a'.repeat(64), 1);
  93. tc.addNewKey('%a.sha256', 'b'.repeat(64));
  94. const idx = tc.buildFingerprintIndex();
  95. eq(idx.size, 2);
  96. const fpA = tc.fingerprint('a'.repeat(64));
  97. const fpB = tc.fingerprint('b'.repeat(64));
  98. eq(idx.get(fpA).rootId, '%a.sha256');
  99. eq(idx.get(fpB).isCurrent, true);
  100. eq(idx.get(fpA).isCurrent, false);
  101. });
  102. });
  103. describe('crypto: wrap/unwrap', (t) => {
  104. t('roundtrip preserves body', () => {
  105. const tc = tribeCryptoFactory(fresh());
  106. const k = tc.generateTribeKey();
  107. tc.setKey('%root.sha256', k, 1);
  108. const body = { k: 'tribe', op: 'create', title: 'x' };
  109. const env = tc.wrapMsg(body, k);
  110. eq(env.type, 'tribe-msg');
  111. eq(env.v, 1);
  112. eq(env.fp, tc.fingerprint(k));
  113. const r = tc.unwrapMsg(env, tc.buildFingerprintIndex());
  114. deepEq(r.body, body);
  115. eq(r.rootId, '%root.sha256');
  116. });
  117. t('unwrap with empty fpIdx returns null', () => {
  118. const tc = tribeCryptoFactory(fresh());
  119. const env = tc.wrapMsg({ k: 'tribe' }, tc.generateTribeKey());
  120. eq(tc.unwrapMsg(env, new Map()), null);
  121. });
  122. t('tampering envelope.fp invalidates ciphertext (AAD)', () => {
  123. const tc = tribeCryptoFactory(fresh());
  124. const k = tc.generateTribeKey();
  125. tc.setKey('%root.sha256', k, 1);
  126. const env = tc.wrapMsg({ k: 'tribe' }, k);
  127. env.fp = 'f'.repeat(32);
  128. eq(tc.unwrapMsg(env, tc.buildFingerprintIndex()), null);
  129. });
  130. });
  131. describe('crypto: invites', (t) => {
  132. t('encrypt/decrypt invite chain roundtrip', () => {
  133. const tc = tribeCryptoFactory(fresh());
  134. const k = tc.generateTribeKey();
  135. tc.setKey('%root.sha256', k, 1);
  136. const code = 'mycode';
  137. const salt = tc.generateInviteSalt();
  138. const ek = tc.encryptChainForInvite(['%root.sha256'], code, salt);
  139. const chain = tc.decryptChainFromInvite(ek, code, salt);
  140. eq(chain[0].rootId, '%root.sha256');
  141. eq(chain[0].key, k);
  142. });
  143. t('decrypt with wrong code returns null', () => {
  144. const tc = tribeCryptoFactory(fresh());
  145. const k = tc.generateTribeKey();
  146. tc.setKey('%root.sha256', k, 1);
  147. const salt = tc.generateInviteSalt();
  148. const ek = tc.encryptChainForInvite(['%root.sha256'], 'right', salt);
  149. eq(tc.decryptChainFromInvite(ek, 'wrong', salt), null);
  150. });
  151. t('inviteMatchesCode via codeHash', () => {
  152. const tc = tribeCryptoFactory(fresh());
  153. const code = 'abc123';
  154. const salt = tc.generateInviteSalt();
  155. const inv = { codeHash: tc.hashInviteCode(code, salt), salt };
  156. ok(tc.inviteMatchesCode(inv, code));
  157. notOk(tc.inviteMatchesCode(inv, 'other'));
  158. });
  159. });
  160. describe('crypto: AES-GCM primitives', (t) => {
  161. t('roundtrip with AAD', () => {
  162. const tc = tribeCryptoFactory(fresh());
  163. const k = tc.generateTribeKey();
  164. const aad = Buffer.from('test-aad');
  165. eq(tc.decryptWithKey(tc.encryptWithKey('hello', k, aad), k, aad), 'hello');
  166. });
  167. t('decrypt with wrong AAD throws', () => {
  168. const tc = tribeCryptoFactory(fresh());
  169. const k = tc.generateTribeKey();
  170. const enc = tc.encryptWithKey('x', k, Buffer.from('a'));
  171. let threw = false;
  172. try { tc.decryptWithKey(enc, k, Buffer.from('b')); } catch (_) { threw = true; }
  173. ok(threw);
  174. });
  175. });
  176. describe('crypto: legacy encryptContent (audit fix)', (t) => {
  177. t('no no-AAD fallback - tampered envelope rejected', () => {
  178. const tc = tribeCryptoFactory(fresh());
  179. const k = tc.generateTribeKey();
  180. const content = { type: 'chat', tribeId: '%t.sha256', title: 'x', author: '@a', createdAt: 'now' };
  181. const enc = tc.encryptContent(content, [k], true);
  182. const tampered = { ...enc, tribeId: '%other.sha256' };
  183. ok(tc.decryptContent(tampered, [[k]])._undecryptable);
  184. });
  185. });