sello 0.1.1 → 0.1.3

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.
@@ -0,0 +1,349 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHmac,
5
+ createPrivateKey,
6
+ createPublicKey,
7
+ diffieHellman,
8
+ generateKeyPairSync,
9
+ } from "node:crypto";
10
+
11
+ import { concat } from "../cbor.js";
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+ const KEM_ID_DHKEM_X25519_HKDF_SHA256 = 0x0020;
34
+ const KDF_ID_HKDF_SHA256 = 0x0001;
35
+ const AEAD_ID_CHACHA20_POLY1305 = 0x0003;
36
+
37
+ const X25519_KEY_LENGTH = 32;
38
+ const HKDF_SHA256_LENGTH = 32;
39
+ const CHACHA20_POLY1305_KEY_LENGTH = 32;
40
+ const CHACHA20_POLY1305_NONCE_LENGTH = 12;
41
+ const CHACHA20_POLY1305_TAG_LENGTH = 16;
42
+
43
+ const X25519_PUBLIC_KEY_SPKI_PREFIX = hex("302a300506032b656e032100");
44
+ const X25519_PRIVATE_KEY_PKCS8_PREFIX = hex("302e020100300506032b656e04220420");
45
+ const EMPTY = new Uint8Array();
46
+ const HPKE_VERSION = ascii("HPKE-v1");
47
+ const KEM_SUITE_ID = concat([
48
+ ascii("KEM"),
49
+ i2osp(KEM_ID_DHKEM_X25519_HKDF_SHA256, 2),
50
+ ]);
51
+ const HPKE_SUITE_ID = concat([
52
+ ascii("HPKE"),
53
+ i2osp(KEM_ID_DHKEM_X25519_HKDF_SHA256, 2),
54
+ i2osp(KDF_ID_HKDF_SHA256, 2),
55
+ i2osp(AEAD_ID_CHACHA20_POLY1305, 2),
56
+ ]);
57
+
58
+ export function generateHpkeKeyPair() {
59
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
60
+
61
+ return {
62
+ publicKey: exportRawX25519PublicKey(publicKey),
63
+ privateKey: exportRawX25519PrivateKey(privateKey),
64
+ };
65
+ }
66
+
67
+ export function sealHpkeBase(input ) {
68
+ assertBytes(input.plaintext, "plaintext");
69
+ assertBytes(input.aad, "aad");
70
+ assertBytes(input.info, "info");
71
+ assertByteLength(input.recipientPublicKey, X25519_KEY_LENGTH, "recipientPublicKey");
72
+
73
+ const ephemeralPrivateKey = input.ephemeralPrivateKey ?? generateHpkeKeyPair().privateKey;
74
+ assertByteLength(ephemeralPrivateKey, X25519_KEY_LENGTH, "ephemeralPrivateKey");
75
+
76
+ const enc = publicKeyFromPrivateKey(ephemeralPrivateKey);
77
+ const sharedSecret = encap(ephemeralPrivateKey, input.recipientPublicKey, enc);
78
+ const context = keySchedule(sharedSecret, input.info);
79
+ const ciphertext = aeadSeal(context.key, context.baseNonce, input.aad, input.plaintext);
80
+
81
+ return concat([enc, ciphertext]);
82
+ }
83
+
84
+ export function openHpkeBase(input ) {
85
+ assertBytes(input.payload, "payload");
86
+ assertBytes(input.aad, "aad");
87
+ assertBytes(input.info, "info");
88
+ assertByteLength(input.recipientPrivateKey, X25519_KEY_LENGTH, "recipientPrivateKey");
89
+
90
+ if (input.payload.byteLength < X25519_KEY_LENGTH + CHACHA20_POLY1305_TAG_LENGTH) {
91
+ throw new TypeError("HPKE payload is too short");
92
+ }
93
+
94
+ const enc = input.payload.subarray(0, X25519_KEY_LENGTH);
95
+ const ciphertext = input.payload.subarray(X25519_KEY_LENGTH);
96
+ const recipientPublicKey = publicKeyFromPrivateKey(input.recipientPrivateKey);
97
+ const sharedSecret = decap(enc, input.recipientPrivateKey, recipientPublicKey);
98
+ const context = keySchedule(sharedSecret, input.info);
99
+
100
+ return aeadOpen(context.key, context.baseNonce, input.aad, ciphertext);
101
+ }
102
+
103
+ function encap(
104
+ ephemeralPrivateKey ,
105
+ recipientPublicKey ,
106
+ enc ,
107
+ ) {
108
+ const dh = x25519(ephemeralPrivateKey, recipientPublicKey);
109
+ return extractAndExpand(dh, concat([enc, recipientPublicKey]));
110
+ }
111
+
112
+ function decap(
113
+ enc ,
114
+ recipientPrivateKey ,
115
+ recipientPublicKey ,
116
+ ) {
117
+ const dh = x25519(recipientPrivateKey, enc);
118
+ return extractAndExpand(dh, concat([enc, recipientPublicKey]));
119
+ }
120
+
121
+ function extractAndExpand(dh , kemContext ) {
122
+ const eaePrk = labeledExtract(KEM_SUITE_ID, EMPTY, "eae_prk", dh);
123
+ return labeledExpand(
124
+ KEM_SUITE_ID,
125
+ eaePrk,
126
+ "shared_secret",
127
+ kemContext,
128
+ HKDF_SHA256_LENGTH,
129
+ );
130
+ }
131
+
132
+ function keySchedule(
133
+ sharedSecret ,
134
+ info ,
135
+ ) {
136
+ const pskIdHash = labeledExtract(
137
+ HPKE_SUITE_ID,
138
+ EMPTY,
139
+ "psk_id_hash",
140
+ EMPTY,
141
+ );
142
+ const infoHash = labeledExtract(HPKE_SUITE_ID, EMPTY, "info_hash", info);
143
+ const keyScheduleContext = concat([Uint8Array.of(0), pskIdHash, infoHash]);
144
+ const secret = labeledExtract(HPKE_SUITE_ID, sharedSecret, "secret", EMPTY);
145
+
146
+ return {
147
+ key: labeledExpand(
148
+ HPKE_SUITE_ID,
149
+ secret,
150
+ "key",
151
+ keyScheduleContext,
152
+ CHACHA20_POLY1305_KEY_LENGTH,
153
+ ),
154
+ baseNonce: labeledExpand(
155
+ HPKE_SUITE_ID,
156
+ secret,
157
+ "base_nonce",
158
+ keyScheduleContext,
159
+ CHACHA20_POLY1305_NONCE_LENGTH,
160
+ ),
161
+ };
162
+ }
163
+
164
+ function labeledExtract(
165
+ suiteId ,
166
+ salt ,
167
+ label ,
168
+ ikm ,
169
+ ) {
170
+ return hkdfExtract(
171
+ salt,
172
+ concat([HPKE_VERSION, suiteId, ascii(label), ikm]),
173
+ );
174
+ }
175
+
176
+ function labeledExpand(
177
+ suiteId ,
178
+ prk ,
179
+ label ,
180
+ info ,
181
+ length ,
182
+ ) {
183
+ return hkdfExpand(
184
+ prk,
185
+ concat([i2osp(length, 2), HPKE_VERSION, suiteId, ascii(label), info]),
186
+ length,
187
+ );
188
+ }
189
+
190
+ function hkdfExtract(salt , ikm ) {
191
+ const hmacKey = salt.byteLength === 0 ? new Uint8Array(HKDF_SHA256_LENGTH) : salt;
192
+ return new Uint8Array(createHmac("sha256", hmacKey).update(ikm).digest());
193
+ }
194
+
195
+ function hkdfExpand(prk , info , length ) {
196
+ if (length > 255 * HKDF_SHA256_LENGTH) {
197
+ throw new RangeError("HKDF output length is too large");
198
+ }
199
+
200
+ const blocks = [];
201
+ let previous = EMPTY;
202
+ let remaining = length;
203
+
204
+ for (let counter = 1; remaining > 0; counter += 1) {
205
+ previous = new Uint8Array(
206
+ createHmac("sha256", prk)
207
+ .update(previous)
208
+ .update(info)
209
+ .update(Uint8Array.of(counter))
210
+ .digest(),
211
+ );
212
+ blocks.push(previous);
213
+ remaining -= previous.byteLength;
214
+ }
215
+
216
+ return concat(blocks).subarray(0, length);
217
+ }
218
+
219
+ function aeadSeal(
220
+ key ,
221
+ nonce ,
222
+ aad ,
223
+ plaintext ,
224
+ ) {
225
+ const cipher = createCipheriv("chacha20-poly1305", key, nonce, {
226
+ authTagLength: CHACHA20_POLY1305_TAG_LENGTH,
227
+ });
228
+
229
+ cipher.setAAD(aad);
230
+ const ciphertext = concat([cipher.update(plaintext), cipher.final()]);
231
+ return concat([ciphertext, cipher.getAuthTag()]);
232
+ }
233
+
234
+ function aeadOpen(
235
+ key ,
236
+ nonce ,
237
+ aad ,
238
+ ciphertext ,
239
+ ) {
240
+ if (ciphertext.byteLength < CHACHA20_POLY1305_TAG_LENGTH) {
241
+ throw new TypeError("HPKE ciphertext is too short");
242
+ }
243
+
244
+ const tag = ciphertext.subarray(ciphertext.byteLength - CHACHA20_POLY1305_TAG_LENGTH);
245
+ const body = ciphertext.subarray(0, ciphertext.byteLength - CHACHA20_POLY1305_TAG_LENGTH);
246
+ const decipher = createDecipheriv("chacha20-poly1305", key, nonce, {
247
+ authTagLength: CHACHA20_POLY1305_TAG_LENGTH,
248
+ });
249
+
250
+ decipher.setAAD(aad);
251
+ decipher.setAuthTag(tag);
252
+
253
+ try {
254
+ return concat([decipher.update(body), decipher.final()]);
255
+ } catch {
256
+ throw new TypeError("HPKE open failed");
257
+ }
258
+ }
259
+
260
+ function x25519(privateKey , publicKey ) {
261
+ const sharedSecret = new Uint8Array(
262
+ diffieHellman({
263
+ privateKey: createX25519PrivateKey(privateKey),
264
+ publicKey: createX25519PublicKey(publicKey),
265
+ }),
266
+ );
267
+
268
+ if (sharedSecret.every((byte) => byte === 0)) {
269
+ throw new TypeError("X25519 shared secret must not be all zero");
270
+ }
271
+
272
+ return sharedSecret;
273
+ }
274
+
275
+ function publicKeyFromPrivateKey(privateKey ) {
276
+ return exportRawX25519PublicKey(createPublicKey(createX25519PrivateKey(privateKey)));
277
+ }
278
+
279
+ function createX25519PublicKey(rawPublicKey ) {
280
+ assertByteLength(rawPublicKey, X25519_KEY_LENGTH, "rawPublicKey");
281
+ return createPublicKey({
282
+ key: Buffer.from(concat([X25519_PUBLIC_KEY_SPKI_PREFIX, rawPublicKey])),
283
+ format: "der",
284
+ type: "spki",
285
+ });
286
+ }
287
+
288
+ function createX25519PrivateKey(rawPrivateKey ) {
289
+ assertByteLength(rawPrivateKey, X25519_KEY_LENGTH, "rawPrivateKey");
290
+ return createPrivateKey({
291
+ key: Buffer.from(concat([X25519_PRIVATE_KEY_PKCS8_PREFIX, rawPrivateKey])),
292
+ format: "der",
293
+ type: "pkcs8",
294
+ });
295
+ }
296
+
297
+ function exportRawX25519PublicKey(key ) {
298
+ const der = new Uint8Array(key.export({ format: "der", type: "spki" }));
299
+ return new Uint8Array(der.subarray(der.byteLength - X25519_KEY_LENGTH));
300
+ }
301
+
302
+ function exportRawX25519PrivateKey(key ) {
303
+ const der = new Uint8Array(key.export({ format: "der", type: "pkcs8" }));
304
+ return new Uint8Array(der.subarray(der.byteLength - X25519_KEY_LENGTH));
305
+ }
306
+
307
+ function i2osp(value , length ) {
308
+ if (!Number.isSafeInteger(value) || value < 0) {
309
+ throw new TypeError("I2OSP value must be a non-negative safe integer");
310
+ }
311
+
312
+ const out = new Uint8Array(length);
313
+ let remaining = value;
314
+
315
+ for (let index = length - 1; index >= 0; index -= 1) {
316
+ out[index] = remaining & 0xff;
317
+ remaining >>= 8;
318
+ }
319
+
320
+ if (remaining !== 0) {
321
+ throw new RangeError("I2OSP value does not fit");
322
+ }
323
+
324
+ return out;
325
+ }
326
+
327
+ function assertByteLength(
328
+ value ,
329
+ length ,
330
+ name ,
331
+ ) {
332
+ if (!(value instanceof Uint8Array) || value.byteLength !== length) {
333
+ throw new TypeError(`${name} must be a ${length}-byte Uint8Array`);
334
+ }
335
+ }
336
+
337
+ function assertBytes(value , name ) {
338
+ if (!(value instanceof Uint8Array)) {
339
+ throw new TypeError(`${name} must be a Uint8Array`);
340
+ }
341
+ }
342
+
343
+ function ascii(value ) {
344
+ return new TextEncoder().encode(value);
345
+ }
346
+
347
+ function hex(value ) {
348
+ return Uint8Array.from(Buffer.from(value, "hex"));
349
+ }
@@ -0,0 +1,79 @@
1
+ import { encodeCbor } from "../cbor.js";
2
+ import { assertTokenRef } from "../crypto/identifiers.js";
3
+ import {
4
+ generateHpkeKeyPair,
5
+ openHpkeBase,
6
+ sealHpkeBase,
7
+
8
+ } from "./base.js";
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+ const HPKE_PAYLOAD_MIN_LENGTH = 49;
30
+
31
+ export { generateHpkeKeyPair };
32
+
33
+ export function buildReceiptHpkeInfo(
34
+ serviceIdentifier ,
35
+ selloTokenRef ,
36
+ ) {
37
+ if (typeof serviceIdentifier !== "string" || serviceIdentifier.length === 0) {
38
+ throw new TypeError("serviceIdentifier must be a non-empty string");
39
+ }
40
+
41
+ assertTokenRef(selloTokenRef, "selloTokenRef");
42
+
43
+ return encodeCbor(["sello/0.1.0/receipt", serviceIdentifier, selloTokenRef]);
44
+ }
45
+
46
+ export function sealReceiptBody(input ) {
47
+ assertBytes(input.plaintext, "plaintext");
48
+ assertBytes(input.protectedHeaderBytes, "protectedHeaderBytes");
49
+
50
+ return sealHpkeBase({
51
+ plaintext: input.plaintext,
52
+ aad: input.protectedHeaderBytes,
53
+ info: buildReceiptHpkeInfo(input.serviceIdentifier, input.selloTokenRef),
54
+ recipientPublicKey: input.ownerPublicKey,
55
+ ephemeralPrivateKey: input.ephemeralPrivateKey,
56
+ });
57
+ }
58
+
59
+ export function openReceiptBody(input ) {
60
+ assertBytes(input.payload, "payload");
61
+ assertBytes(input.protectedHeaderBytes, "protectedHeaderBytes");
62
+
63
+ if (input.payload.byteLength < HPKE_PAYLOAD_MIN_LENGTH) {
64
+ throw new TypeError("HPKE payload must be at least 49 bytes");
65
+ }
66
+
67
+ return openHpkeBase({
68
+ payload: input.payload,
69
+ aad: input.protectedHeaderBytes,
70
+ info: buildReceiptHpkeInfo(input.serviceIdentifier, input.selloTokenRef),
71
+ recipientPrivateKey: input.ownerPrivateKey,
72
+ });
73
+ }
74
+
75
+ function assertBytes(value , name ) {
76
+ if (!(value instanceof Uint8Array)) {
77
+ throw new TypeError(`${name} must be a Uint8Array`);
78
+ }
79
+ }
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./cose/protected-header.js";
2
+ export * from "./cose/sign1.js";
3
+ export * from "./crypto/identifiers.js";
4
+ export * from "./hpke/receipt.js";
5
+ export * from "./log/canonical-url.js";
6
+ export * from "./log/mock-log.js";
7
+ export * from "./log/rekor.js";
8
+ export * from "./log/types.js";
9
+ export * from "./mcp/middleware.js";
10
+ export * from "./owner/verify.js";
11
+ export * from "./registry/json-registry.js";
12
+ export * from "./receipt/body.js";
13
+ export * from "./sdk/index.js";
14
+ export * from "./service/create-receipt.js";
15
+ export * from "./token/jws-profile.js";
@@ -0,0 +1,168 @@
1
+
2
+
3
+ const UNRESERVED = /^[A-Za-z0-9._~-]$/;
4
+
5
+ export function isCanonicalLogUrl(value ) {
6
+ try {
7
+ assertCanonicalLogUrl(value);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export function assertCanonicalLogUrl(
15
+ value ,
16
+ name = "logUrl",
17
+ ) {
18
+ if (typeof value !== "string") {
19
+ throw new TypeError(`${name} must be a string`);
20
+ }
21
+
22
+ if (!value.startsWith("https://")) {
23
+ throw new TypeError(`${name} must use lowercase https scheme`);
24
+ }
25
+
26
+ let parsed ;
27
+ try {
28
+ parsed = new URL(value);
29
+ } catch {
30
+ throw new TypeError(`${name} must be a valid URL`);
31
+ }
32
+
33
+ if (parsed.protocol !== "https:") {
34
+ throw new TypeError(`${name} must use https`);
35
+ }
36
+
37
+ if (parsed.username !== "" || parsed.password !== "") {
38
+ throw new TypeError(`${name} must not contain userinfo`);
39
+ }
40
+
41
+ if (parsed.search !== "") {
42
+ throw new TypeError(`${name} must not contain a query string`);
43
+ }
44
+
45
+ if (parsed.hash !== "") {
46
+ throw new TypeError(`${name} must not contain a fragment`);
47
+ }
48
+
49
+ const authority = getAuthority(value);
50
+ if (authority.includes("@")) {
51
+ throw new TypeError(`${name} must not contain userinfo`);
52
+ }
53
+
54
+ const { rawHost, rawPort } = splitAuthority(authority);
55
+ if (rawHost === "") {
56
+ throw new TypeError(`${name} must contain a host`);
57
+ }
58
+
59
+ if (/[A-Z]/.test(rawHost)) {
60
+ throw new TypeError(`${name} host must be lowercase`);
61
+ }
62
+
63
+ if (rawPort === "443") {
64
+ throw new TypeError(`${name} must omit default port :443`);
65
+ }
66
+
67
+ if (rawPort !== undefined && !/^[0-9]+$/.test(rawPort)) {
68
+ throw new TypeError(`${name} port must be numeric`);
69
+ }
70
+
71
+ const rawPath = getRawPath(value);
72
+ if (rawPath === "") {
73
+ throw new TypeError(`${name} must include a path prefix`);
74
+ }
75
+
76
+ if (!rawPath.startsWith("/")) {
77
+ throw new TypeError(`${name} path must start with /`);
78
+ }
79
+
80
+ if (rawPath.length > 1 && rawPath.endsWith("/")) {
81
+ throw new TypeError(`${name} must not have a trailing slash`);
82
+ }
83
+
84
+ assertPercentEncoding(rawPath, name);
85
+ assertNoDotSegments(rawPath, name);
86
+ }
87
+
88
+ export function logUrlsEqual(a , b ) {
89
+ assertCanonicalLogUrl(a, "a");
90
+ assertCanonicalLogUrl(b, "b");
91
+ return a === b;
92
+ }
93
+
94
+ function getAuthority(value ) {
95
+ const withoutScheme = value.slice("https://".length);
96
+ const end = withoutScheme.search(/[/?#]/);
97
+ return end === -1 ? withoutScheme : withoutScheme.slice(0, end);
98
+ }
99
+
100
+ function getRawPath(value ) {
101
+ const withoutScheme = value.slice("https://".length);
102
+ const pathStart = withoutScheme.search(/[/?#]/);
103
+ if (pathStart === -1 || withoutScheme[pathStart] !== "/") {
104
+ return "";
105
+ }
106
+
107
+ const pathAndSuffix = withoutScheme.slice(pathStart);
108
+ const end = pathAndSuffix.search(/[?#]/);
109
+ return end === -1 ? pathAndSuffix : pathAndSuffix.slice(0, end);
110
+ }
111
+
112
+ function splitAuthority(authority ) {
113
+ if (authority.startsWith("[")) {
114
+ const closing = authority.indexOf("]");
115
+ if (closing === -1) {
116
+ return { rawHost: authority };
117
+ }
118
+ const rawHost = authority.slice(0, closing + 1);
119
+ const rest = authority.slice(closing + 1);
120
+ return rest.startsWith(":")
121
+ ? { rawHost, rawPort: rest.slice(1) }
122
+ : { rawHost };
123
+ }
124
+
125
+ const colon = authority.lastIndexOf(":");
126
+ if (colon === -1) {
127
+ return { rawHost: authority };
128
+ }
129
+
130
+ return {
131
+ rawHost: authority.slice(0, colon),
132
+ rawPort: authority.slice(colon + 1),
133
+ };
134
+ }
135
+
136
+ function assertPercentEncoding(rawPath , name ) {
137
+ for (let index = 0; index < rawPath.length; index += 1) {
138
+ if (rawPath[index] !== "%") {
139
+ continue;
140
+ }
141
+
142
+ const hex = rawPath.slice(index + 1, index + 3);
143
+ if (!/^[0-9A-Fa-f]{2}$/.test(hex)) {
144
+ throw new TypeError(`${name} contains invalid percent-encoding`);
145
+ }
146
+
147
+ if (hex !== hex.toUpperCase()) {
148
+ throw new TypeError(`${name} percent-encoding hex digits must be uppercase`);
149
+ }
150
+
151
+ const decoded = String.fromCharCode(Number.parseInt(hex, 16));
152
+ if (UNRESERVED.test(decoded)) {
153
+ throw new TypeError(`${name} must not percent-encode unreserved characters`);
154
+ }
155
+
156
+ index += 2;
157
+ }
158
+ }
159
+
160
+ function assertNoDotSegments(rawPath , name ) {
161
+ const segments = rawPath.split("/");
162
+
163
+ for (const segment of segments) {
164
+ if (segment === "." || segment === "..") {
165
+ throw new TypeError(`${name} must not contain dot segments`);
166
+ }
167
+ }
168
+ }