-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
122 lines (106 loc) · 4.49 KB
/
client.ts
File metadata and controls
122 lines (106 loc) · 4.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// Copyright (C) 2025 imput
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CupError, type CupTicket } from './lib/util.ts';
import { CupParticipant } from './participant.ts';
import * as Bytes from './lib/bytes.ts';
import * as ECDSA from './lib/ecdsa.ts';
/**
* Used for adding CUP parameters to requests, and later
* verifying the server's response signature.
*/
export class CupClient extends CupParticipant {
#key_id: number;
/**
* @param key_id A key ID which is supported by the server
* you are making requests to.
* @param key A public key used for verifying the server's response.
*/
constructor(key_id: number, key: CryptoKey) {
if (key.type !== 'public') {
throw `invalid key type: ${key.type}, must be 'public'`;
}
if (!key.usages.includes('verify')) {
throw `key usages do not include 'verify'`;
}
super({ [key_id]: key });
this.#key_id = key_id;
}
/**
* Takes a request and adds all the necessary CUP stuff to it
* as preparation for sendoff. Pass in `request` before its
* body is consumed. The request is cloned by the method, you
* do not need to clone it yourself.
* @param request Request with unconsumed body. Cloned by the
* function, you don't need to clone it yourself.
* @param nonce An optional nonce override for testing. Do not set
* this in production unless you have a better PRNG
* than what is offered by `crypto.getRandomValues()`.
* @returns An object containing the modified Request, as well as a ticket
* for use when verifying the response.
*/
async wrap(request: Request, nonce?: string): Promise<{ request: Request; ticket: CupTicket }> {
const url = new URL(request.url);
const nonce_ = nonce || crypto.getRandomValues(new Uint32Array(1))[0];
url.searchParams.set('cup2key', `${this.#key_id}:${nonce_}`);
const ticket = await this.makeTicket(new Request(url, request));
url.searchParams.set('cup2hreq', Bytes.toHex(ticket.hash));
return {
request: new Request(url, request),
ticket,
};
}
/**
* Takes the server response and verifies that it was correctly
* signed by the expected ECDSA key, throws if the response is
* not valid.
* @param response Response with unconsumed body. Cloned by the
* function, you don't need to clone it yourself.
* @param ticket Ticket made by the wrap() call.
* @returns Nothing.
*/
async verify(response: Response, ticket: CupTicket): Promise<void> {
const key = this.keys[ticket.keyId]!;
let proof = response.headers.get('X-Cup-Server-Proof');
if (!proof) {
const etag = response.headers.get('ETag');
if (etag && etag.startsWith('W/"') && etag.endsWith('"')) {
proof = etag.substring(3, etag.length - 1);
} else if (etag) {
proof = etag;
}
}
if (!proof) {
throw new CupError('proof is missing in response or invalid');
}
const [sig_, hash_] = proof.split(':');
if (!sig_ || hash_?.length !== 64) {
throw new CupError('signature or hash is missing from proof or invalid');
}
const sig = ECDSA.toRs(Bytes.fromHex(sig_));
const hash = Bytes.fromHex(hash_);
if (!Bytes.bufEq(hash, ticket.hash)) {
throw new CupError('hash mismatch in response proof');
}
const data = await this.makeProofData(response, ticket);
const valid = await crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
sig,
data,
);
if (!valid) {
throw new CupError('signature is invalid');
}
}
}