@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/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
+ }
@@ -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>;