@gryt/crypto 0.1.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/LICENSE +677 -0
- package/README.md +77 -0
- package/dist/attachments.d.ts +57 -0
- package/dist/attachments.js +99 -0
- package/dist/base64.d.ts +27 -0
- package/dist/base64.js +66 -0
- package/dist/comparison-code.d.ts +58 -0
- package/dist/comparison-code.js +105 -0
- package/dist/conversation-encryption.d.ts +116 -0
- package/dist/conversation-encryption.js +111 -0
- package/dist/dm-key-binding.d.ts +93 -0
- package/dist/dm-key-binding.js +208 -0
- package/dist/dm-keys.d.ts +89 -0
- package/dist/dm-keys.js +142 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/dist/member-keys.d.ts +63 -0
- package/dist/member-keys.js +51 -0
- package/dist/message-keys.d.ts +153 -0
- package/dist/message-keys.js +203 -0
- package/dist/peer-keys.d.ts +155 -0
- package/dist/peer-keys.js +152 -0
- package/dist/scope.d.ts +33 -0
- package/dist/scope.js +14 -0
- package/dist/thumbprint.d.ts +17 -0
- package/dist/thumbprint.js +28 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @gryt/crypto
|
|
2
|
+
|
|
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.
|
|
5
|
+
|
|
6
|
+
Used by the desktop client and the mobile app. They run this, not two ports of
|
|
7
|
+
it — two implementations of one envelope is a pair of clients that send each
|
|
8
|
+
other messages nobody can read, with the sender looking at the text they typed
|
|
9
|
+
either way.
|
|
10
|
+
|
|
11
|
+
## What it is
|
|
12
|
+
|
|
13
|
+
- **`dm-keys`** — an X25519 keypair per server, from the same seed the identity
|
|
14
|
+
comes from, and the shared secret between two of them.
|
|
15
|
+
- **`dm-key-binding`** — a short JWT saying "this message key is mine", signed
|
|
16
|
+
by the identity key that joined the server.
|
|
17
|
+
- **`message-keys`** — a random key per message, encrypted once for each member.
|
|
18
|
+
- **`peer-keys`** — pin what you saw first, refuse a change.
|
|
19
|
+
- **`member-keys`** — the same decision over a whole member list.
|
|
20
|
+
- **`conversation-encryption`** — every member has a usable key, or nobody gets
|
|
21
|
+
it sealed.
|
|
22
|
+
- **`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.
|
|
25
|
+
|
|
26
|
+
## Importing it
|
|
27
|
+
|
|
28
|
+
Everything is on the barrel, and every module is also its own subpath:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { sealMessage } from "@gryt/crypto";
|
|
32
|
+
import { sealMessage } from "@gryt/crypto/message-keys";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The subpaths exist because the desktop client re-exports this package through
|
|
36
|
+
its own `@/common` barrel alongside a `peer-keys` of its own — the same
|
|
37
|
+
functions with `localStorage` already supplied. Two star exports of one name is
|
|
38
|
+
ambiguous, and TypeScript drops the name rather than saying so, so it takes the
|
|
39
|
+
other modules by subpath and leaves `peer-keys` to its own file.
|
|
40
|
+
|
|
41
|
+
That makes the file names here part of the published surface.
|
|
42
|
+
`scripts/check-subpaths.mjs` is what stops a rename getting out.
|
|
43
|
+
|
|
44
|
+
## What it deliberately isn't
|
|
45
|
+
|
|
46
|
+
**Platform-specific.** No `crypto.subtle`, which React Native does not have. No
|
|
47
|
+
`btoa` or `atob` either — Hermes has them and they stop agreeing with you about
|
|
48
|
+
bytes above `0x7f`, so `base64.ts` does it from the alphabet up. No storage —
|
|
49
|
+
pins go through a `PeerPinStore` the caller supplies. No network, no React, no
|
|
50
|
+
config.
|
|
51
|
+
|
|
52
|
+
Two exceptions, named where they are: `crypto.getRandomValues`, which every
|
|
53
|
+
target has, and signing a binding, which takes either a WebCrypto key or a
|
|
54
|
+
function, because that is the one place the platforms hold a key differently.
|
|
55
|
+
|
|
56
|
+
**Hiding that a file exists.** An attachment is encrypted and its name, type and
|
|
57
|
+
dimensions go inside the message, but the server still sees that a file was
|
|
58
|
+
uploaded, how big the ciphertext is, and when. Padding the size is separate work
|
|
59
|
+
and is not pretended at.
|
|
60
|
+
|
|
61
|
+
**Forward secret.** A message key comes from the seed and never moves, so a seed
|
|
62
|
+
that leaks reads every message ever sent to it. Signal and Matrix ratchet; this
|
|
63
|
+
does not. That is GRYT-754 and it is a different protocol rather than a setting.
|
|
64
|
+
|
|
65
|
+
**A cryptography library.** The primitives are `@noble/curves`,
|
|
66
|
+
`@noble/hashes` and `@noble/ciphers`. This is the composition of them.
|
|
67
|
+
|
|
68
|
+
## Checks
|
|
69
|
+
|
|
70
|
+
`npm test` runs every `scripts/check-*.mjs` against the built `dist`, which is
|
|
71
|
+
what a client installs. `check-crypto-vectors.mjs` holds bytes produced before
|
|
72
|
+
the WebCrypto-to-noble conversion and nothing regenerates them — a change that
|
|
73
|
+
quietly altered the envelope would leave every message already sent unreadable.
|
|
74
|
+
|
|
75
|
+
## Licence
|
|
76
|
+
|
|
77
|
+
AGPL-3.0-only.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const SEALED_ATTACHMENT_TYPE = "gryt-sealed-attachment";
|
|
2
|
+
/**
|
|
3
|
+
* What a reader needs to open one file, and what a sender knows about it.
|
|
4
|
+
*
|
|
5
|
+
* Every field is hidden from the server. `name` and `mime` are what the picker
|
|
6
|
+
* reported; nothing verifies them, and a client should treat both as text
|
|
7
|
+
* somebody chose — the same care an unencrypted `original_name` has always
|
|
8
|
+
* needed.
|
|
9
|
+
*/
|
|
10
|
+
export interface SealedAttachmentKey {
|
|
11
|
+
/** The file's own key, base64url. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** Its nonce, base64url. */
|
|
14
|
+
iv: string;
|
|
15
|
+
/** What it was called on the sender's machine. */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** What the sender's picker said it was. */
|
|
18
|
+
mime?: string;
|
|
19
|
+
/** The plaintext length, so a reader can draw a size before fetching. */
|
|
20
|
+
size?: number;
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Encrypt a file, ready to upload.
|
|
26
|
+
*
|
|
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.
|
|
32
|
+
*/
|
|
33
|
+
export declare function sealAttachment({ bytes, conversationId, fileId, name, mime, width, height, }: {
|
|
34
|
+
bytes: Uint8Array;
|
|
35
|
+
conversationId: string;
|
|
36
|
+
fileId: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
mime?: string;
|
|
39
|
+
width?: number;
|
|
40
|
+
height?: number;
|
|
41
|
+
}): {
|
|
42
|
+
ciphertext: Uint8Array<ArrayBuffer>;
|
|
43
|
+
meta: SealedAttachmentKey;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Turn the downloaded bytes back into the file.
|
|
47
|
+
*
|
|
48
|
+
* Throws when they do not open. There is no ordinary reason for that — unlike a
|
|
49
|
+
* message, where a member who joined later legitimately has no key — so a
|
|
50
|
+
* caller should say the file is broken rather than draw an empty one.
|
|
51
|
+
*/
|
|
52
|
+
export declare function openAttachment({ ciphertext, conversationId, fileId, meta, }: {
|
|
53
|
+
ciphertext: Uint8Array;
|
|
54
|
+
conversationId: string;
|
|
55
|
+
fileId: string;
|
|
56
|
+
meta: SealedAttachmentKey;
|
|
57
|
+
}): Uint8Array<ArrayBuffer>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
2
|
+
import { base64Url, base64UrlDecode } from "./base64.js";
|
|
3
|
+
/**
|
|
4
|
+
* A file the server stores and cannot read (GRYT-729 left this out).
|
|
5
|
+
*
|
|
6
|
+
* The message body has been unreadable to the server since GRYT-718. The files
|
|
7
|
+
* hanging off it were not: an upload went up as itself, the server validated
|
|
8
|
+
* it, made a thumbnail, recorded its name, type and dimensions, and served it
|
|
9
|
+
* back to anybody with the link. So a conversation could be private and its
|
|
10
|
+
* photographs public, which is the failure mode where the words are the part
|
|
11
|
+
* nobody needed.
|
|
12
|
+
*
|
|
13
|
+
* ## The shape
|
|
14
|
+
*
|
|
15
|
+
* Each file gets its own random key. The bytes are encrypted under it before
|
|
16
|
+
* they leave the device, and the key — with the real name, type and size —
|
|
17
|
+
* travels inside the sealed message, encrypted again under that message's
|
|
18
|
+
* content key. So opening a file needs the message, and opening the message
|
|
19
|
+
* needs a wrapped key, which is the property the text already had.
|
|
20
|
+
*
|
|
21
|
+
* A key per file rather than the message's own content key, because the two
|
|
22
|
+
* have different lifetimes: an upload happens while somebody is still typing,
|
|
23
|
+
* and can be cancelled, retried or attached to a different message. Deriving it
|
|
24
|
+
* from a message that does not exist yet would mean re-encrypting the file when
|
|
25
|
+
* the draft changed.
|
|
26
|
+
*
|
|
27
|
+
* ## What the server still learns
|
|
28
|
+
*
|
|
29
|
+
* That a file exists, how big the ciphertext is, and when. Not its name, not
|
|
30
|
+
* its type, not its contents, and not its dimensions. Padding the size is a
|
|
31
|
+
* different piece of work and is not pretended at here.
|
|
32
|
+
*
|
|
33
|
+
* ## One shot, not a stream
|
|
34
|
+
*
|
|
35
|
+
* The whole file is encrypted in memory. Uploads are capped by the server —
|
|
36
|
+
* 100 MB by default — and the client is already holding the bytes to send them,
|
|
37
|
+
* so this adds a copy rather than a new problem. A streaming format would be
|
|
38
|
+
* better for the top of that range and is a different envelope; if it happens,
|
|
39
|
+
* it happens as a version 2 rather than as a change to this one.
|
|
40
|
+
*/
|
|
41
|
+
const IV_BYTES = 12;
|
|
42
|
+
const FILE_KEY_BYTES = 32;
|
|
43
|
+
export const SEALED_ATTACHMENT_TYPE = "gryt-sealed-attachment";
|
|
44
|
+
function randomBytes(length) {
|
|
45
|
+
const bytes = new Uint8Array(length);
|
|
46
|
+
crypto.getRandomValues(bytes);
|
|
47
|
+
return bytes;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* What the ciphertext is bound to.
|
|
51
|
+
*
|
|
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.
|
|
56
|
+
*
|
|
57
|
+
* The conversation goes in for the reason `message-keys.ts` gives: the same
|
|
58
|
+
* pair talking in two places must not be able to have a file replayed between
|
|
59
|
+
* them.
|
|
60
|
+
*/
|
|
61
|
+
function fileContext(conversationId, fileId) {
|
|
62
|
+
return new TextEncoder().encode(`${SEALED_ATTACHMENT_TYPE}/v1/${conversationId}/${fileId}`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Encrypt a file, ready to upload.
|
|
66
|
+
*
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
export function sealAttachment({ bytes, conversationId, fileId, name, mime, width, height, }) {
|
|
74
|
+
const key = randomBytes(FILE_KEY_BYTES);
|
|
75
|
+
const iv = randomBytes(IV_BYTES);
|
|
76
|
+
const ciphertext = gcm(key, iv, fileContext(conversationId, fileId)).encrypt(bytes);
|
|
77
|
+
return {
|
|
78
|
+
ciphertext: ciphertext,
|
|
79
|
+
meta: {
|
|
80
|
+
key: base64Url(key),
|
|
81
|
+
iv: base64Url(iv),
|
|
82
|
+
size: bytes.length,
|
|
83
|
+
...(name === undefined ? null : { name }),
|
|
84
|
+
...(mime === undefined ? null : { mime }),
|
|
85
|
+
...(width === undefined ? null : { width }),
|
|
86
|
+
...(height === undefined ? null : { height }),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Turn the downloaded bytes back into the file.
|
|
92
|
+
*
|
|
93
|
+
* Throws when they do not open. There is no ordinary reason for that — unlike a
|
|
94
|
+
* message, where a member who joined later legitimately has no key — so a
|
|
95
|
+
* caller should say the file is broken rather than draw an empty one.
|
|
96
|
+
*/
|
|
97
|
+
export function openAttachment({ ciphertext, conversationId, fileId, meta, }) {
|
|
98
|
+
return gcm(base64UrlDecode(meta.key), base64UrlDecode(meta.iv), fileContext(conversationId, fileId)).decrypt(ciphertext);
|
|
99
|
+
}
|
package/dist/base64.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64url, without `btoa` and `atob`.
|
|
3
|
+
*
|
|
4
|
+
* Every module here used to carry its own two-line pair built on the host's
|
|
5
|
+
* base64: a string assembled one `String.fromCharCode` at a time, handed to
|
|
6
|
+
* `btoa`. That is fine in a browser and it is what the client did for a year.
|
|
7
|
+
*
|
|
8
|
+
* It is not safe on Hermes, and the mobile app's own `encoding.ts` says so in a
|
|
9
|
+
* comment written by somebody who hit it — a byte above `0x7f` and the engine's
|
|
10
|
+
* idea of a binary string stops matching yours. This package exists so the two
|
|
11
|
+
* clients run one implementation, so it cannot be the one that only works on
|
|
12
|
+
* one of them. Bytes in, ASCII out, no globals involved.
|
|
13
|
+
*
|
|
14
|
+
* Byte-for-byte identical to what `btoa` produced. `check-crypto-vectors.mjs`
|
|
15
|
+
* holds envelopes from before this file existed and is what says so.
|
|
16
|
+
*/
|
|
17
|
+
export declare function base64Url(bytes: Uint8Array): string;
|
|
18
|
+
/**
|
|
19
|
+
* Takes either alphabet and padding or none of it.
|
|
20
|
+
*
|
|
21
|
+
* The callers disagreed about this before they shared a decoder: one stripped
|
|
22
|
+
* `+/` into `-_` and passed whatever padding arrived to `atob`, the other did
|
|
23
|
+
* not. Both shapes turn up — a binding is unpadded and a wrapped key is written
|
|
24
|
+
* by this file — so accepting both is the honest reading rather than a
|
|
25
|
+
* convenience.
|
|
26
|
+
*/
|
|
27
|
+
export declare function base64UrlDecode(value: string): Uint8Array<ArrayBuffer>;
|
package/dist/base64.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* base64url, without `btoa` and `atob`.
|
|
3
|
+
*
|
|
4
|
+
* Every module here used to carry its own two-line pair built on the host's
|
|
5
|
+
* base64: a string assembled one `String.fromCharCode` at a time, handed to
|
|
6
|
+
* `btoa`. That is fine in a browser and it is what the client did for a year.
|
|
7
|
+
*
|
|
8
|
+
* It is not safe on Hermes, and the mobile app's own `encoding.ts` says so in a
|
|
9
|
+
* comment written by somebody who hit it — a byte above `0x7f` and the engine's
|
|
10
|
+
* idea of a binary string stops matching yours. This package exists so the two
|
|
11
|
+
* clients run one implementation, so it cannot be the one that only works on
|
|
12
|
+
* one of them. Bytes in, ASCII out, no globals involved.
|
|
13
|
+
*
|
|
14
|
+
* Byte-for-byte identical to what `btoa` produced. `check-crypto-vectors.mjs`
|
|
15
|
+
* holds envelopes from before this file existed and is what says so.
|
|
16
|
+
*/
|
|
17
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
18
|
+
export function base64Url(bytes) {
|
|
19
|
+
let out = "";
|
|
20
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
21
|
+
const a = bytes[i];
|
|
22
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
|
23
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
|
24
|
+
out += ALPHABET[a >> 2];
|
|
25
|
+
out += ALPHABET[((a & 0x03) << 4) | ((b ?? 0) >> 4)];
|
|
26
|
+
if (b === undefined)
|
|
27
|
+
break;
|
|
28
|
+
out += ALPHABET[((b & 0x0f) << 2) | ((c ?? 0) >> 6)];
|
|
29
|
+
if (c === undefined)
|
|
30
|
+
break;
|
|
31
|
+
out += ALPHABET[c & 0x3f];
|
|
32
|
+
}
|
|
33
|
+
// Unpadded, which is what base64url means in a JWT.
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
const LOOKUP = new Map();
|
|
37
|
+
for (let i = 0; i < ALPHABET.length; i++)
|
|
38
|
+
LOOKUP.set(ALPHABET[i], i);
|
|
39
|
+
/**
|
|
40
|
+
* Takes either alphabet and padding or none of it.
|
|
41
|
+
*
|
|
42
|
+
* The callers disagreed about this before they shared a decoder: one stripped
|
|
43
|
+
* `+/` into `-_` and passed whatever padding arrived to `atob`, the other did
|
|
44
|
+
* not. Both shapes turn up — a binding is unpadded and a wrapped key is written
|
|
45
|
+
* by this file — so accepting both is the honest reading rather than a
|
|
46
|
+
* convenience.
|
|
47
|
+
*/
|
|
48
|
+
export function base64UrlDecode(value) {
|
|
49
|
+
const clean = value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
50
|
+
const out = new Uint8Array(Math.floor((clean.length * 6) / 8));
|
|
51
|
+
let bits = 0;
|
|
52
|
+
let acc = 0;
|
|
53
|
+
let written = 0;
|
|
54
|
+
for (const ch of clean) {
|
|
55
|
+
const digit = LOOKUP.get(ch);
|
|
56
|
+
if (digit === undefined)
|
|
57
|
+
throw new Error("Not base64url");
|
|
58
|
+
acc = (acc << 6) | digit;
|
|
59
|
+
bits += 6;
|
|
60
|
+
if (bits >= 8) {
|
|
61
|
+
bits -= 8;
|
|
62
|
+
out[written++] = (acc >> bits) & 0xff;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.subarray(0, written);
|
|
66
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A code two people read to each other (GRYT-730).
|
|
3
|
+
*
|
|
4
|
+
* Everything since GRYT-720 catches a key that *changed*. None of it can say
|
|
5
|
+
* the first key was ever the right one, because the server is what introduced
|
|
6
|
+
* the two people and no check made through the server gets past that. This is
|
|
7
|
+
* the way out, and it is the only one: two people compare a short string over
|
|
8
|
+
* something the server is not on — a phone call, a doorway — and if it matches,
|
|
9
|
+
* neither of them is talking to the server.
|
|
10
|
+
*
|
|
11
|
+
* ## What goes into it
|
|
12
|
+
*
|
|
13
|
+
* Everything both sides have pinned about each other: both identity
|
|
14
|
+
* thumbprints and both DM public keys. A server that substituted any one of the
|
|
15
|
+
* four makes the two codes differ, and there is nothing it can do about that
|
|
16
|
+
* because it never sees the comparison.
|
|
17
|
+
*
|
|
18
|
+
* The four are sorted before hashing, so both people compute the same code
|
|
19
|
+
* without having to agree on who is first. That is the whole reason for the
|
|
20
|
+
* sort, and it is why the two halves are labelled rather than positional.
|
|
21
|
+
*
|
|
22
|
+
* ## What it cannot say
|
|
23
|
+
*
|
|
24
|
+
* Whose keys they are. Matching codes mean the two of you hold the keys you
|
|
25
|
+
* think you hold; they say nothing about the nickname on the other end, and a
|
|
26
|
+
* code compared with the wrong person matches perfectly. That is not a gap this
|
|
27
|
+
* can close and no design closes it — at some point somebody recognises a voice.
|
|
28
|
+
*
|
|
29
|
+
* ## Digits, deliberately
|
|
30
|
+
*
|
|
31
|
+
* Not words. `identity-seed.ts` already renders 24 words from the BIP39 list
|
|
32
|
+
* for the identity backup, and eight more words on a card beside it would read
|
|
33
|
+
* as a second recovery phrase. The one thing this must never be mistaken for is
|
|
34
|
+
* something worth typing into a box or keeping secret — it is public, and it is
|
|
35
|
+
* meant to be read out loud.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Sixty digits is a little under 200 bits, which is far more than the work of
|
|
39
|
+
* grinding a key to match matters at — the number is chosen for reading aloud
|
|
40
|
+
* rather than for the margin. Twelve groups of five is what fits a card in
|
|
41
|
+
* three rows and what somebody can keep their place in halfway down a phone
|
|
42
|
+
* call.
|
|
43
|
+
*/
|
|
44
|
+
export declare const COMPARISON_CODE_DIGITS: number;
|
|
45
|
+
export interface ComparisonSide {
|
|
46
|
+
/** Their identity key's JWK thumbprint, as pinned. */
|
|
47
|
+
thumbprint: string;
|
|
48
|
+
/** Their DM public key, base64url, as pinned. */
|
|
49
|
+
dmPublicKey: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The code for one pair.
|
|
53
|
+
*
|
|
54
|
+
* Both sides go in, sorted, so the two people compute the same string. Neither
|
|
55
|
+
* has to know which of them is "first", and there is no ordering rule to get
|
|
56
|
+
* wrong on one platform and right on another.
|
|
57
|
+
*/
|
|
58
|
+
export declare function comparisonCode(a: ComparisonSide, b: ComparisonSide): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A code two people read to each other (GRYT-730).
|
|
3
|
+
*
|
|
4
|
+
* Everything since GRYT-720 catches a key that *changed*. None of it can say
|
|
5
|
+
* the first key was ever the right one, because the server is what introduced
|
|
6
|
+
* the two people and no check made through the server gets past that. This is
|
|
7
|
+
* the way out, and it is the only one: two people compare a short string over
|
|
8
|
+
* something the server is not on — a phone call, a doorway — and if it matches,
|
|
9
|
+
* neither of them is talking to the server.
|
|
10
|
+
*
|
|
11
|
+
* ## What goes into it
|
|
12
|
+
*
|
|
13
|
+
* Everything both sides have pinned about each other: both identity
|
|
14
|
+
* thumbprints and both DM public keys. A server that substituted any one of the
|
|
15
|
+
* four makes the two codes differ, and there is nothing it can do about that
|
|
16
|
+
* because it never sees the comparison.
|
|
17
|
+
*
|
|
18
|
+
* The four are sorted before hashing, so both people compute the same code
|
|
19
|
+
* without having to agree on who is first. That is the whole reason for the
|
|
20
|
+
* sort, and it is why the two halves are labelled rather than positional.
|
|
21
|
+
*
|
|
22
|
+
* ## What it cannot say
|
|
23
|
+
*
|
|
24
|
+
* Whose keys they are. Matching codes mean the two of you hold the keys you
|
|
25
|
+
* think you hold; they say nothing about the nickname on the other end, and a
|
|
26
|
+
* code compared with the wrong person matches perfectly. That is not a gap this
|
|
27
|
+
* can close and no design closes it — at some point somebody recognises a voice.
|
|
28
|
+
*
|
|
29
|
+
* ## Digits, deliberately
|
|
30
|
+
*
|
|
31
|
+
* Not words. `identity-seed.ts` already renders 24 words from the BIP39 list
|
|
32
|
+
* for the identity backup, and eight more words on a card beside it would read
|
|
33
|
+
* as a second recovery phrase. The one thing this must never be mistaken for is
|
|
34
|
+
* something worth typing into a box or keeping secret — it is public, and it is
|
|
35
|
+
* meant to be read out loud.
|
|
36
|
+
*/
|
|
37
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
38
|
+
/** How many digits, and why that many. */
|
|
39
|
+
const GROUPS = 12;
|
|
40
|
+
const DIGITS_PER_GROUP = 5;
|
|
41
|
+
/**
|
|
42
|
+
* Sixty digits is a little under 200 bits, which is far more than the work of
|
|
43
|
+
* grinding a key to match matters at — the number is chosen for reading aloud
|
|
44
|
+
* rather than for the margin. Twelve groups of five is what fits a card in
|
|
45
|
+
* three rows and what somebody can keep their place in halfway down a phone
|
|
46
|
+
* call.
|
|
47
|
+
*/
|
|
48
|
+
export const COMPARISON_CODE_DIGITS = GROUPS * DIGITS_PER_GROUP;
|
|
49
|
+
/**
|
|
50
|
+
* Enough bytes for one digit each, from a hash that only produces 32.
|
|
51
|
+
*
|
|
52
|
+
* Counting rather than wrapping. Wrapping a 32-byte digest around 60 digits
|
|
53
|
+
* repeats the first 28 of them at the end, which halves what the code actually
|
|
54
|
+
* distinguishes and — worse — is visible: the printed code has a run in it that
|
|
55
|
+
* looks like a bug and invites somebody to stop comparing.
|
|
56
|
+
*/
|
|
57
|
+
function stretch(seed, count) {
|
|
58
|
+
const out = new Uint8Array(count);
|
|
59
|
+
for (let block = 0; block * 32 < count; block++) {
|
|
60
|
+
const chunk = sha256(new Uint8Array([...seed, block]));
|
|
61
|
+
out.set(chunk.subarray(0, Math.min(32, count - block * 32)), block * 32);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function digitsFrom(bytes, count) {
|
|
66
|
+
// Every digit from its own byte, taken modulo ten. That is very slightly
|
|
67
|
+
// biased — 256 is not a multiple of 10 — and it does not matter here: this is
|
|
68
|
+
// a fingerprint to compare, not a secret to guess. Rejection sampling would
|
|
69
|
+
// buy a fraction of a bit and a branch that is hard to test.
|
|
70
|
+
let out = "";
|
|
71
|
+
for (let i = 0; i < count; i++)
|
|
72
|
+
out += (bytes[i] % 10).toString();
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The code for one pair.
|
|
77
|
+
*
|
|
78
|
+
* Both sides go in, sorted, so the two people compute the same string. Neither
|
|
79
|
+
* has to know which of them is "first", and there is no ordering rule to get
|
|
80
|
+
* wrong on one platform and right on another.
|
|
81
|
+
*/
|
|
82
|
+
export function comparisonCode(a, b) {
|
|
83
|
+
const halves = [
|
|
84
|
+
[a.thumbprint, a.dmPublicKey],
|
|
85
|
+
[b.thumbprint, b.dmPublicKey],
|
|
86
|
+
]
|
|
87
|
+
.map((half) => JSON.stringify(half))
|
|
88
|
+
.sort();
|
|
89
|
+
/*
|
|
90
|
+
* JSON rather than joining on a separator.
|
|
91
|
+
*
|
|
92
|
+
* `thumbprint + ":" + key` is ambiguous: a thumbprint of "a" with a key of
|
|
93
|
+
* "b:c" and a thumbprint of "a:b" with a key of "c" produce the same string,
|
|
94
|
+
* so two different pairs of keys get the same code. Neither field contains a
|
|
95
|
+
* colon today — both are base64url — which makes it the kind of thing that is
|
|
96
|
+
* fine until somebody changes what goes in here. Quoting removes the question.
|
|
97
|
+
*/
|
|
98
|
+
const digest = sha256(new TextEncoder().encode(JSON.stringify(halves)));
|
|
99
|
+
const digits = digitsFrom(stretch(digest, COMPARISON_CODE_DIGITS), COMPARISON_CODE_DIGITS);
|
|
100
|
+
const groups = [];
|
|
101
|
+
for (let i = 0; i < COMPARISON_CODE_DIGITS; i += DIGITS_PER_GROUP) {
|
|
102
|
+
groups.push(digits.slice(i, i + DIGITS_PER_GROUP));
|
|
103
|
+
}
|
|
104
|
+
return groups.join(" ");
|
|
105
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a conversation can be encrypted, and doing it (GRYT-729).
|
|
3
|
+
*
|
|
4
|
+
* Everything underneath this has existed for a while and had no caller.
|
|
5
|
+
* `sealMessage` since GRYT-718, keys published and pinned since GRYT-727. This
|
|
6
|
+
* is the decision that turns them on, and it is a decision rather than a
|
|
7
|
+
* capability check: it can say no, and when it says no the answer has to reach
|
|
8
|
+
* the person about to press send.
|
|
9
|
+
*
|
|
10
|
+
* ## Every member, or nobody
|
|
11
|
+
*
|
|
12
|
+
* A message is sealed only when every member of the conversation has a key this
|
|
13
|
+
* client is willing to use. One member without one is not a reason to seal for
|
|
14
|
+
* the rest — they would be unable to read a conversation they are in, silently,
|
|
15
|
+
* and the sender would have no idea.
|
|
16
|
+
*
|
|
17
|
+
* A member whose key *changed* counts as not having one. That is the refusal
|
|
18
|
+
* from GRYT-726 arriving where it matters: nothing is encrypted to a key this
|
|
19
|
+
* client has decided not to trust, and nothing falls back to plaintext without
|
|
20
|
+
* saying so either.
|
|
21
|
+
*
|
|
22
|
+
* ## Saying no out loud
|
|
23
|
+
*
|
|
24
|
+
* {@link SealDecision} carries who is missing and why. A composer that quietly
|
|
25
|
+
* sends in the clear because somebody has not updated their client is the exact
|
|
26
|
+
* failure this whole design exists to avoid, and it is invisible from the
|
|
27
|
+
* outside — the message sends, it arrives, it reads normally.
|
|
28
|
+
*/
|
|
29
|
+
import type { SealedAttachmentKey } from "./attachments.js";
|
|
30
|
+
import { type OpenedMessage } from "./message-keys.js";
|
|
31
|
+
import { type PeerKeyDecision } from "./peer-keys.js";
|
|
32
|
+
export interface ConversationMember {
|
|
33
|
+
/** `server_user_id`, which is how the conversation names them. */
|
|
34
|
+
memberId: string;
|
|
35
|
+
/** What this client decided about their key, from `evaluateMemberKeys`. */
|
|
36
|
+
keyState?: {
|
|
37
|
+
decision: PeerKeyDecision;
|
|
38
|
+
} | undefined;
|
|
39
|
+
}
|
|
40
|
+
export type SealDecision = {
|
|
41
|
+
kind: "seal";
|
|
42
|
+
recipients: {
|
|
43
|
+
memberId: string;
|
|
44
|
+
publicKey: Uint8Array;
|
|
45
|
+
}[];
|
|
46
|
+
} | {
|
|
47
|
+
kind: "plaintext";
|
|
48
|
+
/**
|
|
49
|
+
* Why, per member, so a composer can name them rather than saying
|
|
50
|
+
* "encryption unavailable" and leaving somebody to guess.
|
|
51
|
+
*/
|
|
52
|
+
blockedBy: {
|
|
53
|
+
memberId: string;
|
|
54
|
+
reason: "no-key" | "changed" | "unusable";
|
|
55
|
+
}[];
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Can this conversation be sealed, and to whom.
|
|
59
|
+
*
|
|
60
|
+
* `self` is included in the recipients, because a sender who cannot read their
|
|
61
|
+
* own message back has sent something they will look at tomorrow and find
|
|
62
|
+
* empty. `sealMessage` refuses a recipient list without them for that reason;
|
|
63
|
+
* this is where they are put in.
|
|
64
|
+
*/
|
|
65
|
+
export declare function decideSealing({ members, self, }: {
|
|
66
|
+
/** Everybody in the conversation apart from you. */
|
|
67
|
+
members: ConversationMember[];
|
|
68
|
+
/** Your own member id and DM public key on this server. */
|
|
69
|
+
self: {
|
|
70
|
+
memberId: string;
|
|
71
|
+
publicKey: Uint8Array;
|
|
72
|
+
} | null;
|
|
73
|
+
}): SealDecision;
|
|
74
|
+
/**
|
|
75
|
+
* Seal a message for a conversation, or say why it cannot be.
|
|
76
|
+
*
|
|
77
|
+
* Returns the envelope as the string that goes on the wire. Null means send it
|
|
78
|
+
* in the clear — and a caller that ignores which of the two it got is back to
|
|
79
|
+
* sending plaintext without telling anybody.
|
|
80
|
+
*/
|
|
81
|
+
export declare function sealForConversation({ plaintext, conversationId, senderKeys, decision, attachments, }: {
|
|
82
|
+
plaintext: string;
|
|
83
|
+
conversationId: string;
|
|
84
|
+
senderKeys: {
|
|
85
|
+
privateKey: Uint8Array;
|
|
86
|
+
publicKey: Uint8Array;
|
|
87
|
+
};
|
|
88
|
+
decision: SealDecision;
|
|
89
|
+
/**
|
|
90
|
+
* File id to what `sealAttachment` returned for it (GRYT-729).
|
|
91
|
+
*
|
|
92
|
+
* A conversation that cannot be sealed returns null here, and a caller that
|
|
93
|
+
* has already encrypted and uploaded files then has an upload nobody can
|
|
94
|
+
* open. Encrypt the files *after* checking `decision.kind`, not before.
|
|
95
|
+
*/
|
|
96
|
+
attachments?: Record<string, SealedAttachmentKey>;
|
|
97
|
+
}): Promise<string | null>;
|
|
98
|
+
/**
|
|
99
|
+
* Read one back.
|
|
100
|
+
*
|
|
101
|
+
* Null when there is no wrapped key for this member — somebody who joined after
|
|
102
|
+
* it was sent — which a client draws as a message it cannot read rather than as
|
|
103
|
+
* an error. Anything else throws, because a key that is present and does not
|
|
104
|
+
* open means tampering or the wrong conversation, and an empty bubble would
|
|
105
|
+
* hide it.
|
|
106
|
+
*/
|
|
107
|
+
export declare function openForConversation({ sealed, conversationId, memberId, recipientKeys, }: {
|
|
108
|
+
/** The string off the wire. */
|
|
109
|
+
sealed: string;
|
|
110
|
+
conversationId: string;
|
|
111
|
+
memberId: string;
|
|
112
|
+
recipientKeys: {
|
|
113
|
+
privateKey: Uint8Array;
|
|
114
|
+
publicKey: Uint8Array;
|
|
115
|
+
};
|
|
116
|
+
}): Promise<OpenedMessage | null>;
|