@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
|
@@ -0,0 +1,111 @@
|
|
|
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 { openMessage, sealMessage, } from "./message-keys.js";
|
|
30
|
+
/**
|
|
31
|
+
* Can this conversation be sealed, and to whom.
|
|
32
|
+
*
|
|
33
|
+
* `self` is included in the recipients, because a sender who cannot read their
|
|
34
|
+
* own message back has sent something they will look at tomorrow and find
|
|
35
|
+
* empty. `sealMessage` refuses a recipient list without them for that reason;
|
|
36
|
+
* this is where they are put in.
|
|
37
|
+
*/
|
|
38
|
+
export function decideSealing({ members, self, }) {
|
|
39
|
+
if (!self) {
|
|
40
|
+
// No key of our own means nothing to seal with, which is a device that has
|
|
41
|
+
// not finished joining rather than a problem with anybody else.
|
|
42
|
+
return { kind: "plaintext", blockedBy: [] };
|
|
43
|
+
}
|
|
44
|
+
const recipients = [
|
|
45
|
+
{ memberId: self.memberId, publicKey: self.publicKey },
|
|
46
|
+
];
|
|
47
|
+
const blockedBy = [];
|
|
48
|
+
for (const member of members) {
|
|
49
|
+
const decision = member.keyState?.decision;
|
|
50
|
+
if (!decision || decision.kind === "none") {
|
|
51
|
+
blockedBy.push({ memberId: member.memberId, reason: "no-key" });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (decision.kind === "unusable") {
|
|
55
|
+
blockedBy.push({ memberId: member.memberId, reason: "unusable" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (decision.kind === "changed") {
|
|
59
|
+
// The refusal from GRYT-726, arriving where it costs something. Falling
|
|
60
|
+
// back to plaintext here is the honest answer; encrypting to the new key
|
|
61
|
+
// would be pretending the change was fine.
|
|
62
|
+
blockedBy.push({ memberId: member.memberId, reason: "changed" });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
recipients.push({
|
|
66
|
+
memberId: member.memberId,
|
|
67
|
+
publicKey: decision.verified.dmPublicKey,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (blockedBy.length > 0)
|
|
71
|
+
return { kind: "plaintext", blockedBy };
|
|
72
|
+
return { kind: "seal", recipients };
|
|
73
|
+
}
|
|
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 async function sealForConversation({ plaintext, conversationId, senderKeys, decision, attachments, }) {
|
|
82
|
+
if (decision.kind !== "seal")
|
|
83
|
+
return null;
|
|
84
|
+
const sealed = await sealMessage({
|
|
85
|
+
plaintext,
|
|
86
|
+
conversationId,
|
|
87
|
+
senderKeys,
|
|
88
|
+
recipients: decision.recipients,
|
|
89
|
+
attachments,
|
|
90
|
+
});
|
|
91
|
+
return JSON.stringify(sealed);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read one back.
|
|
95
|
+
*
|
|
96
|
+
* Null when there is no wrapped key for this member — somebody who joined after
|
|
97
|
+
* it was sent — which a client draws as a message it cannot read rather than as
|
|
98
|
+
* an error. Anything else throws, because a key that is present and does not
|
|
99
|
+
* open means tampering or the wrong conversation, and an empty bubble would
|
|
100
|
+
* hide it.
|
|
101
|
+
*/
|
|
102
|
+
export async function openForConversation({ sealed, conversationId, memberId, recipientKeys, }) {
|
|
103
|
+
let envelope;
|
|
104
|
+
try {
|
|
105
|
+
envelope = JSON.parse(sealed);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error("That message is not a sealed envelope.");
|
|
109
|
+
}
|
|
110
|
+
return openMessage({ sealed: envelope, conversationId, memberId, recipientKeys });
|
|
111
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying that a DM key and an identity key belong to the same person (GRYT-720).
|
|
3
|
+
*
|
|
4
|
+
* `dm-keys.ts` derives the key a message is encrypted to. Nothing says whose it
|
|
5
|
+
* is, and a key handed over by a server that could have made it up is worth
|
|
6
|
+
* nothing — a server that wanted to read a conversation would give each side its
|
|
7
|
+
* own key and relay.
|
|
8
|
+
*
|
|
9
|
+
* This is one link of the chain that answers that: a short JWT, signed by the
|
|
10
|
+
* per-server identity key, saying "this DM public key is mine, on this server".
|
|
11
|
+
*
|
|
12
|
+
* ## What it proves, exactly
|
|
13
|
+
*
|
|
14
|
+
* That whoever holds the identity key also chose this DM key. Nothing else. In
|
|
15
|
+
* particular it does **not** say whose identity key it is — the public half
|
|
16
|
+
* rides in the header, so a server can mint a keypair and sign a perfectly
|
|
17
|
+
* valid binding with it.
|
|
18
|
+
*
|
|
19
|
+
* That is not a hole in this file, it is where the problem actually lives.
|
|
20
|
+
* Nothing verifiable in band can say who a key belongs to; the regress stops at
|
|
21
|
+
* something pinned earlier or something compared out of band, and at nothing
|
|
22
|
+
* else. What this buys is that the two keys are now one thing to substitute
|
|
23
|
+
* instead of two, and the identity key is the one the server challenged at join
|
|
24
|
+
* — so a server handing out a forged binding is contradicting a proof it
|
|
25
|
+
* verified itself, in front of every member at once.
|
|
26
|
+
*
|
|
27
|
+
* The caller pins {@link VerifiedDmKeyBinding.identityThumbprint}. That is the
|
|
28
|
+
* part that means something, and `server-pins.ts` already does the same three
|
|
29
|
+
* moves for server keys: pin on first sight, detect a change, refuse it.
|
|
30
|
+
*
|
|
31
|
+
* ## Why the key is inside the signed statement
|
|
32
|
+
*
|
|
33
|
+
* A server storing a DM key and a signature as two fields could serve one
|
|
34
|
+
* person's key with another's signature, and a client checking them separately
|
|
35
|
+
* might not notice. There is one field: the binding. The key is read out of it
|
|
36
|
+
* after the signature verifies, or it is not read at all.
|
|
37
|
+
*/
|
|
38
|
+
import { type IdentityScope } from "./scope.js";
|
|
39
|
+
export interface VerifiedDmKeyBinding {
|
|
40
|
+
/** The X25519 public key, raw bytes, once the signature has been checked. */
|
|
41
|
+
dmPublicKey: Uint8Array<ArrayBuffer>;
|
|
42
|
+
/**
|
|
43
|
+
* The identity key that signed this, as a JWK thumbprint.
|
|
44
|
+
*
|
|
45
|
+
* **This is the thing to pin.** Everything else in here is a statement by
|
|
46
|
+
* whoever holds that key, and is worth exactly what the key is worth.
|
|
47
|
+
*/
|
|
48
|
+
identityThumbprint: string;
|
|
49
|
+
/** The scope the binding claims, already checked against the expected one. */
|
|
50
|
+
scope: IdentityScope;
|
|
51
|
+
/** When it was signed, seconds since the epoch. */
|
|
52
|
+
signedAt: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Sign the statement.
|
|
56
|
+
*
|
|
57
|
+
* There is no expiry. The DM key is derived from the seed and the scope, so it
|
|
58
|
+
* does not roll and a binding does not go stale — and an expiry a client cannot
|
|
59
|
+
* renew while offline would make old messages unreadable for a reason that has
|
|
60
|
+
* nothing to do with anybody's keys. `signedAt` is there so a verifier can
|
|
61
|
+
* prefer the newer of two bindings if one ever does change, which is a
|
|
62
|
+
* different question from whether this one is still good.
|
|
63
|
+
*/
|
|
64
|
+
export declare function signDmKeyBinding({ dmPublicKey, scope, identityPrivateKey, identityPublicJwk, now, }: {
|
|
65
|
+
dmPublicKey: Uint8Array;
|
|
66
|
+
scope: IdentityScope;
|
|
67
|
+
/**
|
|
68
|
+
* A `CryptoKey`, or a function that signs bytes with the identity key.
|
|
69
|
+
*
|
|
70
|
+
* Two shapes because the two clients hold the key differently: the desktop
|
|
71
|
+
* has a WebCrypto handle, and React Native has raw bytes and a curve library
|
|
72
|
+
* (GRYT-733). Verifying is pure and shared — every client does it for every
|
|
73
|
+
* peer — while signing happens once, with your own key, and is the one place
|
|
74
|
+
* the platforms genuinely differ.
|
|
75
|
+
*/
|
|
76
|
+
identityPrivateKey: CryptoKey | ((bytes: Uint8Array) => Promise<Uint8Array>);
|
|
77
|
+
/** Rides in the header, so a verifier that has never seen it can check. */
|
|
78
|
+
identityPublicJwk: JsonWebKey;
|
|
79
|
+
now?: number;
|
|
80
|
+
}): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Check a binding, and refuse it rather than returning something partly checked.
|
|
83
|
+
*
|
|
84
|
+
* Throws on anything wrong. There is no "probably fine" here: a caller that got
|
|
85
|
+
* a value back has a DM key whose signature verified under the thumbprint it was
|
|
86
|
+
* handed, and a caller that did not has nothing to think about.
|
|
87
|
+
*
|
|
88
|
+
* `expectedScope` is required. Without it a binding signed for one server can be
|
|
89
|
+
* replayed by another, which is the cheapest attack available to any operator
|
|
90
|
+
* who can see a member list — and the scope is the one thing the verifier
|
|
91
|
+
* already knows for certain, because it is the server it is talking to.
|
|
92
|
+
*/
|
|
93
|
+
export declare function verifyDmKeyBinding(binding: string, expectedScope: IdentityScope): Promise<VerifiedDmKeyBinding>;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saying that a DM key and an identity key belong to the same person (GRYT-720).
|
|
3
|
+
*
|
|
4
|
+
* `dm-keys.ts` derives the key a message is encrypted to. Nothing says whose it
|
|
5
|
+
* is, and a key handed over by a server that could have made it up is worth
|
|
6
|
+
* nothing — a server that wanted to read a conversation would give each side its
|
|
7
|
+
* own key and relay.
|
|
8
|
+
*
|
|
9
|
+
* This is one link of the chain that answers that: a short JWT, signed by the
|
|
10
|
+
* per-server identity key, saying "this DM public key is mine, on this server".
|
|
11
|
+
*
|
|
12
|
+
* ## What it proves, exactly
|
|
13
|
+
*
|
|
14
|
+
* That whoever holds the identity key also chose this DM key. Nothing else. In
|
|
15
|
+
* particular it does **not** say whose identity key it is — the public half
|
|
16
|
+
* rides in the header, so a server can mint a keypair and sign a perfectly
|
|
17
|
+
* valid binding with it.
|
|
18
|
+
*
|
|
19
|
+
* That is not a hole in this file, it is where the problem actually lives.
|
|
20
|
+
* Nothing verifiable in band can say who a key belongs to; the regress stops at
|
|
21
|
+
* something pinned earlier or something compared out of band, and at nothing
|
|
22
|
+
* else. What this buys is that the two keys are now one thing to substitute
|
|
23
|
+
* instead of two, and the identity key is the one the server challenged at join
|
|
24
|
+
* — so a server handing out a forged binding is contradicting a proof it
|
|
25
|
+
* verified itself, in front of every member at once.
|
|
26
|
+
*
|
|
27
|
+
* The caller pins {@link VerifiedDmKeyBinding.identityThumbprint}. That is the
|
|
28
|
+
* part that means something, and `server-pins.ts` already does the same three
|
|
29
|
+
* moves for server keys: pin on first sight, detect a change, refuse it.
|
|
30
|
+
*
|
|
31
|
+
* ## Why the key is inside the signed statement
|
|
32
|
+
*
|
|
33
|
+
* A server storing a DM key and a signature as two fields could serve one
|
|
34
|
+
* person's key with another's signature, and a client checking them separately
|
|
35
|
+
* might not notice. There is one field: the binding. The key is read out of it
|
|
36
|
+
* after the signature verifies, or it is not read at all.
|
|
37
|
+
*/
|
|
38
|
+
/*
|
|
39
|
+
* The `.ts` is for Node's type stripping, which `check-dm-key-binding.mjs` runs
|
|
40
|
+
* this file through and which does no extension inference. `message-keys.ts`
|
|
41
|
+
* carries the same one for the same reason; `dm-keys.ts` does not, because its
|
|
42
|
+
* import from here is type-only and erases.
|
|
43
|
+
*/
|
|
44
|
+
import { p256 } from "@noble/curves/nist.js";
|
|
45
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
46
|
+
import { base64Url, base64UrlDecode } from "./base64.js";
|
|
47
|
+
import { asIdentityScope } from "./scope.js";
|
|
48
|
+
import { jwkThumbprint } from "./thumbprint.js";
|
|
49
|
+
/**
|
|
50
|
+
* The `iss` a binding carries.
|
|
51
|
+
*
|
|
52
|
+
* Constant rather than the signer's own id, because a binding is not addressed
|
|
53
|
+
* to a server and has no subject to name. What it is *for* is the thing worth
|
|
54
|
+
* writing down, so a JWT that arrives on this path and says something else is
|
|
55
|
+
* refused rather than read hopefully.
|
|
56
|
+
*/
|
|
57
|
+
const BINDING_ISSUER = "gryt:dm-key";
|
|
58
|
+
/**
|
|
59
|
+
* A JWK's public point as the uncompressed bytes the curve library takes.
|
|
60
|
+
*
|
|
61
|
+
* `0x04`, then x, then y — the same layout `identity-seed.ts` slices apart when
|
|
62
|
+
* it builds a JWK from a derived key, put back together.
|
|
63
|
+
*/
|
|
64
|
+
function jwkToPoint(jwk) {
|
|
65
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256") {
|
|
66
|
+
throw new Error("A DM key binding is signed with a P-256 key.");
|
|
67
|
+
}
|
|
68
|
+
if (typeof jwk.x !== "string" || typeof jwk.y !== "string") {
|
|
69
|
+
throw new Error("That DM key binding's key has no coordinates.");
|
|
70
|
+
}
|
|
71
|
+
const x = base64UrlDecode(jwk.x);
|
|
72
|
+
const y = base64UrlDecode(jwk.y);
|
|
73
|
+
if (x.length !== 32 || y.length !== 32) {
|
|
74
|
+
throw new Error("A P-256 coordinate is 32 bytes.");
|
|
75
|
+
}
|
|
76
|
+
const point = new Uint8Array(65);
|
|
77
|
+
point[0] = 0x04;
|
|
78
|
+
point.set(x, 1);
|
|
79
|
+
point.set(y, 33);
|
|
80
|
+
return point;
|
|
81
|
+
}
|
|
82
|
+
function utf8(value) {
|
|
83
|
+
return new TextEncoder().encode(value);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sign the statement.
|
|
87
|
+
*
|
|
88
|
+
* There is no expiry. The DM key is derived from the seed and the scope, so it
|
|
89
|
+
* does not roll and a binding does not go stale — and an expiry a client cannot
|
|
90
|
+
* renew while offline would make old messages unreadable for a reason that has
|
|
91
|
+
* nothing to do with anybody's keys. `signedAt` is there so a verifier can
|
|
92
|
+
* prefer the newer of two bindings if one ever does change, which is a
|
|
93
|
+
* different question from whether this one is still good.
|
|
94
|
+
*/
|
|
95
|
+
export async function signDmKeyBinding({ dmPublicKey, scope, identityPrivateKey, identityPublicJwk, now = Math.floor(Date.now() / 1000), }) {
|
|
96
|
+
const header = {
|
|
97
|
+
alg: "ES256",
|
|
98
|
+
typ: "JWT",
|
|
99
|
+
jwk: identityPublicJwk,
|
|
100
|
+
};
|
|
101
|
+
const payload = {
|
|
102
|
+
iss: BINDING_ISSUER,
|
|
103
|
+
scope,
|
|
104
|
+
dm: base64Url(dmPublicKey),
|
|
105
|
+
iat: now,
|
|
106
|
+
};
|
|
107
|
+
const signingInput = `${base64Url(utf8(JSON.stringify(header)))}.${base64Url(utf8(JSON.stringify(payload)))}`;
|
|
108
|
+
const bytes = utf8(signingInput);
|
|
109
|
+
const signature = typeof identityPrivateKey === "function"
|
|
110
|
+
? await identityPrivateKey(bytes)
|
|
111
|
+
: new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identityPrivateKey, bytes));
|
|
112
|
+
return `${signingInput}.${base64Url(signature)}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check a binding, and refuse it rather than returning something partly checked.
|
|
116
|
+
*
|
|
117
|
+
* Throws on anything wrong. There is no "probably fine" here: a caller that got
|
|
118
|
+
* a value back has a DM key whose signature verified under the thumbprint it was
|
|
119
|
+
* handed, and a caller that did not has nothing to think about.
|
|
120
|
+
*
|
|
121
|
+
* `expectedScope` is required. Without it a binding signed for one server can be
|
|
122
|
+
* replayed by another, which is the cheapest attack available to any operator
|
|
123
|
+
* who can see a member list — and the scope is the one thing the verifier
|
|
124
|
+
* already knows for certain, because it is the server it is talking to.
|
|
125
|
+
*/
|
|
126
|
+
export async function verifyDmKeyBinding(binding, expectedScope) {
|
|
127
|
+
const parts = binding.split(".");
|
|
128
|
+
if (parts.length !== 3) {
|
|
129
|
+
throw new Error("A DM key binding is a compact JWT with three parts.");
|
|
130
|
+
}
|
|
131
|
+
let header;
|
|
132
|
+
let payload;
|
|
133
|
+
try {
|
|
134
|
+
header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
|
|
135
|
+
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error("That DM key binding is not readable.");
|
|
139
|
+
}
|
|
140
|
+
// Pinned rather than read off the header. `alg: "none"` is the oldest JWT bug
|
|
141
|
+
// there is, and every softer version of it — accepting HS256 and verifying
|
|
142
|
+
// the signature with the public key as the HMAC secret — starts with taking
|
|
143
|
+
// the algorithm from the attacker.
|
|
144
|
+
if (header.alg !== "ES256" || header.typ !== "JWT") {
|
|
145
|
+
throw new Error("A DM key binding is ES256, and this one says otherwise.");
|
|
146
|
+
}
|
|
147
|
+
const jwk = header.jwk;
|
|
148
|
+
if (!jwk || typeof jwk !== "object") {
|
|
149
|
+
throw new Error("That DM key binding carries no key to check it with.");
|
|
150
|
+
}
|
|
151
|
+
if (jwk.d !== undefined) {
|
|
152
|
+
throw new Error("That DM key binding carries private key material.");
|
|
153
|
+
}
|
|
154
|
+
if (payload.iss !== BINDING_ISSUER) {
|
|
155
|
+
throw new Error(`A DM key binding is issued by ${BINDING_ISSUER}.`);
|
|
156
|
+
}
|
|
157
|
+
if (payload.scope !== expectedScope) {
|
|
158
|
+
// Replay from another server. The binding is perfectly valid there.
|
|
159
|
+
throw new Error("That DM key binding was signed for a different server.");
|
|
160
|
+
}
|
|
161
|
+
if (typeof payload.dm !== "string" || typeof payload.iat !== "number") {
|
|
162
|
+
throw new Error("That DM key binding is missing a key or a time.");
|
|
163
|
+
}
|
|
164
|
+
/*
|
|
165
|
+
* Verified with the curve library rather than the platform (GRYT-733).
|
|
166
|
+
*
|
|
167
|
+
* `crypto.subtle` is not on React Native and this file has to run there
|
|
168
|
+
* unchanged. The signature is the same either way: ES256 is P-256 over a
|
|
169
|
+
* SHA-256 digest with a raw sixty-four byte `r || s`, which is what WebCrypto
|
|
170
|
+
* emits and what `p256.verify` takes.
|
|
171
|
+
*/
|
|
172
|
+
const publicKey = jwkToPoint(jwk);
|
|
173
|
+
const signature = base64UrlDecode(parts[2]);
|
|
174
|
+
if (signature.length !== 64) {
|
|
175
|
+
throw new Error("A DM key binding's signature is 64 bytes.");
|
|
176
|
+
}
|
|
177
|
+
/*
|
|
178
|
+
* `lowS: false`, and this is not a relaxation.
|
|
179
|
+
*
|
|
180
|
+
* ECDSA has two valid signatures for every message — `s` and `order - s` —
|
|
181
|
+
* and noble refuses the high one by default, because for a blockchain a
|
|
182
|
+
* signature that can be rewritten while staying valid is a transaction that
|
|
183
|
+
* can be replayed under a second id. Nothing here is identified by its
|
|
184
|
+
* signature.
|
|
185
|
+
*
|
|
186
|
+
* WebCrypto does not normalise, JOSE does not require it, and roughly half of
|
|
187
|
+
* all ES256 signatures come out high. Leaving the default on would have
|
|
188
|
+
* rejected about half of every client's bindings, at random, with the message
|
|
189
|
+
* that the signature did not check out — and the other half would have worked
|
|
190
|
+
* perfectly, which is the shape of bug that survives a lot of testing.
|
|
191
|
+
*/
|
|
192
|
+
const ok = p256.verify(signature, sha256(utf8(`${parts[0]}.${parts[1]}`)), publicKey, { prehash: false, lowS: false });
|
|
193
|
+
if (!ok) {
|
|
194
|
+
throw new Error("That DM key binding's signature does not check out.");
|
|
195
|
+
}
|
|
196
|
+
const dmPublicKey = base64UrlDecode(payload.dm);
|
|
197
|
+
// X25519 public keys are 32 bytes. Anything else is not one, and passing it
|
|
198
|
+
// to the curve library would be the place that found out.
|
|
199
|
+
if (dmPublicKey.length !== 32) {
|
|
200
|
+
throw new Error(`A DM public key is 32 bytes, and this one is ${dmPublicKey.length}.`);
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
dmPublicKey,
|
|
204
|
+
identityThumbprint: jwkThumbprint(jwk),
|
|
205
|
+
scope: asIdentityScope(payload.scope),
|
|
206
|
+
signedAt: payload.iat,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key a direct message is encrypted to (GRYT-709).
|
|
3
|
+
*
|
|
4
|
+
* Separate from the identity key on purpose, and not because it is tidier. The
|
|
5
|
+
* identity key is ECDSA P-256, which signs and cannot do key agreement — there
|
|
6
|
+
* is no operation that turns two ECDSA keys into a shared secret. Encryption
|
|
7
|
+
* needs a curve built for agreement, so this is X25519 and it is a second key.
|
|
8
|
+
*
|
|
9
|
+
* ## Nothing secret ever leaves the device
|
|
10
|
+
*
|
|
11
|
+
* Only {@link dmPublicKey} is published. The private half is derived here, used
|
|
12
|
+
* here, and never sent anywhere — not to the server, not encrypted, not as part
|
|
13
|
+
* of a backup. A server that wanted to read a conversation would have to obtain
|
|
14
|
+
* something that has never been transmitted.
|
|
15
|
+
*
|
|
16
|
+
* What *is* backed up is the seed, which the 24-word phrase already carries. So
|
|
17
|
+
* restoring an identity restores the ability to read old messages, without
|
|
18
|
+
* anything about the messages being stored anywhere but the server that already
|
|
19
|
+
* has them.
|
|
20
|
+
*
|
|
21
|
+
* ## One key per server, like the identity key
|
|
22
|
+
*
|
|
23
|
+
* `identity-seed.ts` derives a separate key for each server so that two of them
|
|
24
|
+
* cannot tell they are talking to the same person. That property is worth
|
|
25
|
+
* exactly as much here, and would be undone by a single DM key shared across
|
|
26
|
+
* servers — so the same scope goes into the derivation.
|
|
27
|
+
*
|
|
28
|
+
* **The scope, not the address (GRYT-719).** `identityScopeFor` gives the
|
|
29
|
+
* server's lineage id, and only falls back to the address for a server that
|
|
30
|
+
* proved nothing. Deriving from the address instead would give a server on a
|
|
31
|
+
* LAN address and a tunnel two DM keys, and would change the key whenever a
|
|
32
|
+
* port is taken or a lease moves. For the identity key that bug meant arriving
|
|
33
|
+
* as a stranger, which GRYT-257 fixed and which is at least visible. Here it
|
|
34
|
+
* would mean every message ever encrypted to the old key is unreadable, with
|
|
35
|
+
* nothing logged. `IdentityScope` is branded so the wrong string does not
|
|
36
|
+
* typecheck.
|
|
37
|
+
*
|
|
38
|
+
* The consequence is that a conversation is bound to the server it happens on,
|
|
39
|
+
* which is what `useDirectMessages.ts` and `conversations.ts` already say about
|
|
40
|
+
* DMs in plaintext. Encryption does not change the shape, it enforces it.
|
|
41
|
+
*
|
|
42
|
+
* ## What this file does not do
|
|
43
|
+
*
|
|
44
|
+
* It derives a keypair and computes a shared secret. It does not publish the
|
|
45
|
+
* public key, does not encrypt a message, and does not know what a conversation
|
|
46
|
+
* is. Wrapping a per-message key for each member, and the certificate that says
|
|
47
|
+
* whose public key is whose, are the parts that follow — and the certificate is
|
|
48
|
+
* the one that decides whether any of this is worth anything, because a shared
|
|
49
|
+
* secret with a key the server chose for you protects nothing.
|
|
50
|
+
*/
|
|
51
|
+
import type { IdentityScope } from "./scope.js";
|
|
52
|
+
export interface DmKeyPair {
|
|
53
|
+
/** Never leaves this device. Not sent, not backed up, not logged. */
|
|
54
|
+
privateKey: Uint8Array;
|
|
55
|
+
/** The half that is published, so others can encrypt to you. */
|
|
56
|
+
publicKey: Uint8Array;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The DM keypair this seed gives for one server.
|
|
60
|
+
*
|
|
61
|
+
* Deterministic: the same seed and scope give the same keypair on any device,
|
|
62
|
+
* which is what makes the recovery phrase enough to read old conversations
|
|
63
|
+
* again — and, because the scope outlives an address change, what makes them
|
|
64
|
+
* still readable after the server moves.
|
|
65
|
+
*
|
|
66
|
+
* Raw bytes rather than a `CryptoKey`, because WebCrypto's X25519 support is
|
|
67
|
+
* younger than the browsers Gryt runs on and the agreement below is done by the
|
|
68
|
+
* curve library anyway. There is nothing to gain from importing a key into an
|
|
69
|
+
* API that is not going to perform the operation.
|
|
70
|
+
*/
|
|
71
|
+
export declare function deriveDmKeyPair(seed: Uint8Array, scope: IdentityScope): DmKeyPair;
|
|
72
|
+
/** The public half alone, for the cases that should not touch the private one. */
|
|
73
|
+
export declare function dmPublicKey(seed: Uint8Array, scope: IdentityScope): Uint8Array;
|
|
74
|
+
/**
|
|
75
|
+
* The secret two people share, from one private half and one public half.
|
|
76
|
+
*
|
|
77
|
+
* Run through HKDF rather than used raw. The X25519 output is a curve point's
|
|
78
|
+
* x-coordinate, which is not uniformly distributed over 32 bytes, and a key
|
|
79
|
+
* derivation function is what turns it into something safe to use as one. The
|
|
80
|
+
* conversation id goes in as `info`, so the same pair of people talking in two
|
|
81
|
+
* conversations do not derive the same key in both.
|
|
82
|
+
*
|
|
83
|
+
* **This is not enough on its own, and the gap is not in the maths.** Agreement
|
|
84
|
+
* with the wrong public key succeeds exactly as well as agreement with the right
|
|
85
|
+
* one — both produce a perfectly good secret. Whether `theirPublicKey` belongs
|
|
86
|
+
* to the person named beside it is the question the certificate answers, and
|
|
87
|
+
* until that exists a caller is trusting the server for it.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dmSharedSecret(privateKey: Uint8Array, theirPublicKey: Uint8Array, conversationId: string): Uint8Array;
|
package/dist/dm-keys.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key a direct message is encrypted to (GRYT-709).
|
|
3
|
+
*
|
|
4
|
+
* Separate from the identity key on purpose, and not because it is tidier. The
|
|
5
|
+
* identity key is ECDSA P-256, which signs and cannot do key agreement — there
|
|
6
|
+
* is no operation that turns two ECDSA keys into a shared secret. Encryption
|
|
7
|
+
* needs a curve built for agreement, so this is X25519 and it is a second key.
|
|
8
|
+
*
|
|
9
|
+
* ## Nothing secret ever leaves the device
|
|
10
|
+
*
|
|
11
|
+
* Only {@link dmPublicKey} is published. The private half is derived here, used
|
|
12
|
+
* here, and never sent anywhere — not to the server, not encrypted, not as part
|
|
13
|
+
* of a backup. A server that wanted to read a conversation would have to obtain
|
|
14
|
+
* something that has never been transmitted.
|
|
15
|
+
*
|
|
16
|
+
* What *is* backed up is the seed, which the 24-word phrase already carries. So
|
|
17
|
+
* restoring an identity restores the ability to read old messages, without
|
|
18
|
+
* anything about the messages being stored anywhere but the server that already
|
|
19
|
+
* has them.
|
|
20
|
+
*
|
|
21
|
+
* ## One key per server, like the identity key
|
|
22
|
+
*
|
|
23
|
+
* `identity-seed.ts` derives a separate key for each server so that two of them
|
|
24
|
+
* cannot tell they are talking to the same person. That property is worth
|
|
25
|
+
* exactly as much here, and would be undone by a single DM key shared across
|
|
26
|
+
* servers — so the same scope goes into the derivation.
|
|
27
|
+
*
|
|
28
|
+
* **The scope, not the address (GRYT-719).** `identityScopeFor` gives the
|
|
29
|
+
* server's lineage id, and only falls back to the address for a server that
|
|
30
|
+
* proved nothing. Deriving from the address instead would give a server on a
|
|
31
|
+
* LAN address and a tunnel two DM keys, and would change the key whenever a
|
|
32
|
+
* port is taken or a lease moves. For the identity key that bug meant arriving
|
|
33
|
+
* as a stranger, which GRYT-257 fixed and which is at least visible. Here it
|
|
34
|
+
* would mean every message ever encrypted to the old key is unreadable, with
|
|
35
|
+
* nothing logged. `IdentityScope` is branded so the wrong string does not
|
|
36
|
+
* typecheck.
|
|
37
|
+
*
|
|
38
|
+
* The consequence is that a conversation is bound to the server it happens on,
|
|
39
|
+
* which is what `useDirectMessages.ts` and `conversations.ts` already say about
|
|
40
|
+
* DMs in plaintext. Encryption does not change the shape, it enforces it.
|
|
41
|
+
*
|
|
42
|
+
* ## What this file does not do
|
|
43
|
+
*
|
|
44
|
+
* It derives a keypair and computes a shared secret. It does not publish the
|
|
45
|
+
* public key, does not encrypt a message, and does not know what a conversation
|
|
46
|
+
* is. Wrapping a per-message key for each member, and the certificate that says
|
|
47
|
+
* whose public key is whose, are the parts that follow — and the certificate is
|
|
48
|
+
* the one that decides whether any of this is worth anything, because a shared
|
|
49
|
+
* secret with a key the server chose for you protects nothing.
|
|
50
|
+
*/
|
|
51
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
52
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
53
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
54
|
+
/**
|
|
55
|
+
* Domain separator, and why it is not the identity one.
|
|
56
|
+
*
|
|
57
|
+
* `identity-seed.ts` uses `gryt-identity-v1`. Deriving both keys from one seed
|
|
58
|
+
* under the same label would hand the same 32 bytes to two different
|
|
59
|
+
* algorithms, which is the kind of reuse that turns two safe primitives into
|
|
60
|
+
* one unsafe system. A different label makes the two keys independent: knowing
|
|
61
|
+
* either tells you nothing about the other, because HKDF's outputs for
|
|
62
|
+
* different `info` are unrelated to anyone without the seed.
|
|
63
|
+
*
|
|
64
|
+
* Versioned for the same reason the identity one is. Changing this string
|
|
65
|
+
* changes every DM key that has ever existed, which would make every message
|
|
66
|
+
* already sent unreadable — so a `v2` arrives with a migration or not at all.
|
|
67
|
+
*/
|
|
68
|
+
const DERIVATION_SALT = "gryt-dm-v1";
|
|
69
|
+
/**
|
|
70
|
+
* Exactly 32, and this is where X25519 differs from the P-256 path next door.
|
|
71
|
+
*
|
|
72
|
+
* `deriveLocalKeyPair` takes 48 bytes and reduces them modulo the curve order,
|
|
73
|
+
* because a P-256 scalar has to land in a range that 32 uniform bytes overshoot
|
|
74
|
+
* slightly, and the excess makes low values fractionally likelier.
|
|
75
|
+
*
|
|
76
|
+
* X25519 has no such range. Any 32 bytes is a valid secret — the algorithm
|
|
77
|
+
* clamps the bits it cares about itself, which is part of its definition rather
|
|
78
|
+
* than something a caller does. So taking more than 32 and reducing would be
|
|
79
|
+
* ceremony that copies the shape of the other function without its reason.
|
|
80
|
+
*/
|
|
81
|
+
const SECRET_BYTES = 32;
|
|
82
|
+
function utf8(value) {
|
|
83
|
+
return new TextEncoder().encode(value);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Refuse a seed this obviously cannot be right.
|
|
87
|
+
*
|
|
88
|
+
* The mirror of `assertUsableSeed` in `identity-seed.ts`, and here for the same
|
|
89
|
+
* reason: a stub or a platform handing back a constant would give every device
|
|
90
|
+
* the same DM key, and every user would be able to read every conversation. A
|
|
91
|
+
* real seed being all one byte has a probability around 2^-248, so nothing
|
|
92
|
+
* legitimate is being turned away.
|
|
93
|
+
*/
|
|
94
|
+
function assertUsableSeed(seed) {
|
|
95
|
+
if (seed.length !== SECRET_BYTES) {
|
|
96
|
+
throw new Error(`A seed is ${SECRET_BYTES} bytes, not ${seed.length}.`);
|
|
97
|
+
}
|
|
98
|
+
if (seed.every((byte) => byte === seed[0])) {
|
|
99
|
+
throw new Error("That seed is a single repeated byte, which is not a seed.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The DM keypair this seed gives for one server.
|
|
104
|
+
*
|
|
105
|
+
* Deterministic: the same seed and scope give the same keypair on any device,
|
|
106
|
+
* which is what makes the recovery phrase enough to read old conversations
|
|
107
|
+
* again — and, because the scope outlives an address change, what makes them
|
|
108
|
+
* still readable after the server moves.
|
|
109
|
+
*
|
|
110
|
+
* Raw bytes rather than a `CryptoKey`, because WebCrypto's X25519 support is
|
|
111
|
+
* younger than the browsers Gryt runs on and the agreement below is done by the
|
|
112
|
+
* curve library anyway. There is nothing to gain from importing a key into an
|
|
113
|
+
* API that is not going to perform the operation.
|
|
114
|
+
*/
|
|
115
|
+
export function deriveDmKeyPair(seed, scope) {
|
|
116
|
+
assertUsableSeed(seed);
|
|
117
|
+
const privateKey = hkdf(sha256, seed, utf8(DERIVATION_SALT), utf8(scope), SECRET_BYTES);
|
|
118
|
+
return { privateKey, publicKey: x25519.getPublicKey(privateKey) };
|
|
119
|
+
}
|
|
120
|
+
/** The public half alone, for the cases that should not touch the private one. */
|
|
121
|
+
export function dmPublicKey(seed, scope) {
|
|
122
|
+
return deriveDmKeyPair(seed, scope).publicKey;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The secret two people share, from one private half and one public half.
|
|
126
|
+
*
|
|
127
|
+
* Run through HKDF rather than used raw. The X25519 output is a curve point's
|
|
128
|
+
* x-coordinate, which is not uniformly distributed over 32 bytes, and a key
|
|
129
|
+
* derivation function is what turns it into something safe to use as one. The
|
|
130
|
+
* conversation id goes in as `info`, so the same pair of people talking in two
|
|
131
|
+
* conversations do not derive the same key in both.
|
|
132
|
+
*
|
|
133
|
+
* **This is not enough on its own, and the gap is not in the maths.** Agreement
|
|
134
|
+
* with the wrong public key succeeds exactly as well as agreement with the right
|
|
135
|
+
* one — both produce a perfectly good secret. Whether `theirPublicKey` belongs
|
|
136
|
+
* to the person named beside it is the question the certificate answers, and
|
|
137
|
+
* until that exists a caller is trusting the server for it.
|
|
138
|
+
*/
|
|
139
|
+
export function dmSharedSecret(privateKey, theirPublicKey, conversationId) {
|
|
140
|
+
const shared = x25519.getSharedSecret(privateKey, theirPublicKey);
|
|
141
|
+
return hkdf(sha256, shared, utf8(`${DERIVATION_SALT}-shared`), utf8(conversationId), SECRET_BYTES);
|
|
142
|
+
}
|