@gryt/crypto 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,14 @@
1
- # @gryt/crypto
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/Gryt-chat/client/main/public/logo.svg" width="80" alt="Gryt logo" />
3
+ <h1>@gryt/crypto</h1>
4
+ <p>Message encryption for <a href="https://gryt.chat">Gryt</a>.<br />Key derivation, key bindings, sealed envelopes, pinning, and the code two people compare out of band.</p>
5
+ </div>
2
6
 
3
- Message encryption for [Gryt](https://gryt.chat): key derivation, key bindings,
4
- sealed envelopes, pinning, and the code two people compare out of band.
7
+ <br />
8
+
9
+ ```sh
10
+ npm install @gryt/crypto
11
+ ```
5
12
 
6
13
  Used by the desktop client and the mobile app. They run this, not two ports of
7
14
  it — two implementations of one envelope is a pair of clients that send each
@@ -20,8 +27,8 @@ either way.
20
27
  - **`conversation-encryption`** — every member has a usable key, or nobody gets
21
28
  it sealed.
22
29
  - **`comparison-code`** — sixty digits two people read to each other.
23
- - **`attachments`** — a key per file, bound to its id, with the key inside the
24
- sealed message.
30
+ - **`attachments`** — a key per file, bound so one file’s bytes cannot be served
31
+ as another’s, with the key inside the sealed message.
25
32
 
26
33
  ## Importing it
27
34
 
@@ -72,6 +79,20 @@ what a client installs. `check-crypto-vectors.mjs` holds bytes produced before
72
79
  the WebCrypto-to-noble conversion and nothing regenerates them — a change that
73
80
  quietly altered the envelope would leave every message already sent unreadable.
74
81
 
75
- ## Licence
82
+ ## Issues
83
+
84
+ Please report bugs and request features in the
85
+ [main Gryt repository](https://github.com/Gryt-chat/gryt/issues).
86
+
87
+ ## Sponsors
88
+
89
+ What sponsoring pays for, the tiers, and everyone who has sponsored:
90
+ [gryt.chat/sponsors](https://gryt.chat/sponsors). To sponsor:
91
+ [GitHub Sponsors](https://github.com/sponsors/Gryt-chat).
92
+
93
+ The list itself lives in the [Gryt README](https://github.com/Gryt-chat/gryt#sponsors),
94
+ in one place rather than ten, so it cannot fall out of step across repositories.
95
+
96
+ ## License
76
97
 
77
- AGPL-3.0-only.
98
+ [AGPL-3.0](https://github.com/Gryt-chat/gryt/blob/main/LICENSE) — Part of [Gryt](https://github.com/Gryt-chat/gryt)
@@ -8,6 +8,23 @@ export declare const SEALED_ATTACHMENT_TYPE = "gryt-sealed-attachment";
8
8
  * needed.
9
9
  */
10
10
  export interface SealedAttachmentKey {
11
+ /**
12
+ * What the ciphertext is bound to. Generated here, not supplied.
13
+ *
14
+ * It used to be the file id, and that could not work: the server assigns the
15
+ * id, and it assigns it in the response to the upload — by which point the
16
+ * bytes have already been encrypted and sent. A caller would have had to
17
+ * choose the id and talk the server into using it, which means a
18
+ * client-chosen primary key and a uniqueness problem that is the server's to
19
+ * lose.
20
+ *
21
+ * A random value chosen here does the same job. The point of binding was
22
+ * never the id itself: it is that a server serving one file's bytes under
23
+ * another's name produces something that does not open. Bound to a value the
24
+ * sender picked and wrote into the encrypted metadata, a swap still fails,
25
+ * and nobody has to agree on an id first.
26
+ */
27
+ id: string;
11
28
  /** The file's own key, base64url. */
12
29
  key: string;
13
30
  /** Its nonce, base64url. */
@@ -24,16 +41,14 @@ export interface SealedAttachmentKey {
24
41
  /**
25
42
  * Encrypt a file, ready to upload.
26
43
  *
27
- * The id has to be decided before the bytes are sealed, because it is what they
28
- * are bound to. Callers generate one rather than taking the server's: an id
29
- * chosen after the upload would mean either re-encrypting or leaving the bytes
30
- * unbound, and a server that assigns the id could then assign the same one
31
- * twice.
44
+ * Nothing has to be agreed with the server first. The value the ciphertext is
45
+ * bound to is generated here and returned in `meta`, so a caller can encrypt,
46
+ * upload, take whatever id the server hands back, and file the metadata under
47
+ * it see {@link SealedAttachmentKey.id}.
32
48
  */
33
- export declare function sealAttachment({ bytes, conversationId, fileId, name, mime, width, height, }: {
49
+ export declare function sealAttachment({ bytes, conversationId, name, mime, width, height, }: {
34
50
  bytes: Uint8Array;
35
51
  conversationId: string;
36
- fileId: string;
37
52
  name?: string;
38
53
  mime?: string;
39
54
  width?: number;
@@ -49,9 +64,8 @@ export declare function sealAttachment({ bytes, conversationId, fileId, name, mi
49
64
  * message, where a member who joined later legitimately has no key — so a
50
65
  * caller should say the file is broken rather than draw an empty one.
51
66
  */
52
- export declare function openAttachment({ ciphertext, conversationId, fileId, meta, }: {
67
+ export declare function openAttachment({ ciphertext, conversationId, meta, }: {
53
68
  ciphertext: Uint8Array;
54
69
  conversationId: string;
55
- fileId: string;
56
70
  meta: SealedAttachmentKey;
57
71
  }): Uint8Array<ArrayBuffer>;
@@ -40,6 +40,14 @@ import { base64Url, base64UrlDecode } from "./base64.js";
40
40
  */
41
41
  const IV_BYTES = 12;
42
42
  const FILE_KEY_BYTES = 32;
43
+ /**
44
+ * Enough that two attachments never collide, and no more.
45
+ *
46
+ * This is not a secret and not a key. It exists so that one file's ciphertext
47
+ * does not open under another's metadata, so all it has to be is unique among
48
+ * the attachments a person sends.
49
+ */
50
+ const BINDING_ID_BYTES = 16;
43
51
  export const SEALED_ATTACHMENT_TYPE = "gryt-sealed-attachment";
44
52
  function randomBytes(length) {
45
53
  const bytes = new Uint8Array(length);
@@ -49,34 +57,36 @@ function randomBytes(length) {
49
57
  /**
50
58
  * What the ciphertext is bound to.
51
59
  *
52
- * The file id goes in, so the bytes stored under one id cannot be served back
53
- * under another and still open. A server that swapped two members' uploads
54
- * would otherwise produce files that decrypt perfectly and are the wrong ones —
55
- * and since the reader never saw the original, nothing would look wrong.
60
+ * The sender's own id for this attachment goes in, so bytes stored under one
61
+ * cannot be served back under another and still open. A server that swapped two
62
+ * members' uploads would otherwise produce files that decrypt perfectly and are
63
+ * the wrong ones — and since the reader never saw the original, nothing would
64
+ * look wrong.
56
65
  *
57
66
  * The conversation goes in for the reason `message-keys.ts` gives: the same
58
67
  * pair talking in two places must not be able to have a file replayed between
59
68
  * them.
60
69
  */
61
- function fileContext(conversationId, fileId) {
62
- return new TextEncoder().encode(`${SEALED_ATTACHMENT_TYPE}/v1/${conversationId}/${fileId}`);
70
+ function fileContext(conversationId, id) {
71
+ return new TextEncoder().encode(`${SEALED_ATTACHMENT_TYPE}/v1/${conversationId}/${id}`);
63
72
  }
64
73
  /**
65
74
  * Encrypt a file, ready to upload.
66
75
  *
67
- * The id has to be decided before the bytes are sealed, because it is what they
68
- * are bound to. Callers generate one rather than taking the server's: an id
69
- * chosen after the upload would mean either re-encrypting or leaving the bytes
70
- * unbound, and a server that assigns the id could then assign the same one
71
- * twice.
76
+ * Nothing has to be agreed with the server first. The value the ciphertext is
77
+ * bound to is generated here and returned in `meta`, so a caller can encrypt,
78
+ * upload, take whatever id the server hands back, and file the metadata under
79
+ * it see {@link SealedAttachmentKey.id}.
72
80
  */
73
- export function sealAttachment({ bytes, conversationId, fileId, name, mime, width, height, }) {
81
+ export function sealAttachment({ bytes, conversationId, name, mime, width, height, }) {
82
+ const id = base64Url(randomBytes(BINDING_ID_BYTES));
74
83
  const key = randomBytes(FILE_KEY_BYTES);
75
84
  const iv = randomBytes(IV_BYTES);
76
- const ciphertext = gcm(key, iv, fileContext(conversationId, fileId)).encrypt(bytes);
85
+ const ciphertext = gcm(key, iv, fileContext(conversationId, id)).encrypt(bytes);
77
86
  return {
78
87
  ciphertext: ciphertext,
79
88
  meta: {
89
+ id,
80
90
  key: base64Url(key),
81
91
  iv: base64Url(iv),
82
92
  size: bytes.length,
@@ -94,6 +104,6 @@ export function sealAttachment({ bytes, conversationId, fileId, name, mime, widt
94
104
  * message, where a member who joined later legitimately has no key — so a
95
105
  * caller should say the file is broken rather than draw an empty one.
96
106
  */
97
- export function openAttachment({ ciphertext, conversationId, fileId, meta, }) {
98
- return gcm(base64UrlDecode(meta.key), base64UrlDecode(meta.iv), fileContext(conversationId, fileId)).decrypt(ciphertext);
107
+ export function openAttachment({ ciphertext, conversationId, meta, }) {
108
+ return gcm(base64UrlDecode(meta.key), base64UrlDecode(meta.iv), fileContext(conversationId, meta.id)).decrypt(ciphertext);
99
109
  }
package/dist/index.d.ts CHANGED
@@ -33,6 +33,7 @@
33
33
  * setting.
34
34
  */
35
35
  export * from "./attachments.js";
36
+ export * from "./base64.js";
36
37
  export * from "./comparison-code.js";
37
38
  export * from "./conversation-encryption.js";
38
39
  export * from "./dm-key-binding.js";
package/dist/index.js CHANGED
@@ -33,6 +33,10 @@
33
33
  * setting.
34
34
  */
35
35
  export * from "./attachments.js";
36
+ // Exported because both apps carry their own copy of exactly this, and one of
37
+ // the two is the `btoa` version this file was written to replace. They cannot
38
+ // drop theirs while it is package-internal.
39
+ export * from "./base64.js";
36
40
  export * from "./comparison-code.js";
37
41
  export * from "./conversation-encryption.js";
38
42
  export * from "./dm-key-binding.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/crypto",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Message encryption for Gryt: key derivation, key bindings, sealed envelopes, pinning and comparison codes. Shared by the desktop client and the mobile app.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {