@lindorm/aegis 0.3.6 → 0.4.1

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +247 -163
  3. package/__tests__/__mocks__/cbor.ts +17 -0
  4. package/__tests__/cose-interop.test.ts +1127 -0
  5. package/__tests__/jwe-interop.test.ts +331 -0
  6. package/__tests__/jwt-interop.test.ts +183 -0
  7. package/dist/classes/Aegis.d.ts.map +1 -1
  8. package/dist/classes/Aegis.js +12 -7
  9. package/dist/classes/Aegis.js.map +1 -1
  10. package/dist/classes/CweKit.d.ts.map +1 -1
  11. package/dist/classes/CweKit.js +31 -37
  12. package/dist/classes/CweKit.js.map +1 -1
  13. package/dist/classes/CwsKit.d.ts.map +1 -1
  14. package/dist/classes/CwsKit.js +8 -3
  15. package/dist/classes/CwsKit.js.map +1 -1
  16. package/dist/classes/CwtKit.d.ts.map +1 -1
  17. package/dist/classes/CwtKit.js +10 -14
  18. package/dist/classes/CwtKit.js.map +1 -1
  19. package/dist/classes/JweKit.d.ts.map +1 -1
  20. package/dist/classes/JweKit.js +24 -47
  21. package/dist/classes/JweKit.js.map +1 -1
  22. package/dist/classes/JwsKit.d.ts.map +1 -1
  23. package/dist/classes/JwsKit.js +9 -2
  24. package/dist/classes/JwsKit.js.map +1 -1
  25. package/dist/classes/JwtKit.d.ts.map +1 -1
  26. package/dist/classes/JwtKit.js +10 -9
  27. package/dist/classes/JwtKit.js.map +1 -1
  28. package/dist/classes/SignatureKit.d.ts.map +1 -1
  29. package/dist/classes/SignatureKit.js +2 -1
  30. package/dist/classes/SignatureKit.js.map +1 -1
  31. package/dist/constants/private/cose.d.ts +0 -1
  32. package/dist/constants/private/cose.d.ts.map +1 -1
  33. package/dist/constants/private/cose.js +5 -23
  34. package/dist/constants/private/cose.js.map +1 -1
  35. package/dist/types/aegis.d.ts +3 -1
  36. package/dist/types/aegis.d.ts.map +1 -1
  37. package/dist/types/cose-target.d.ts +2 -0
  38. package/dist/types/cose-target.d.ts.map +1 -0
  39. package/dist/types/{operators.js → cose-target.js} +1 -1
  40. package/dist/types/cose-target.js.map +1 -0
  41. package/dist/types/cwe/cwe-decode.d.ts +6 -2
  42. package/dist/types/cwe/cwe-decode.d.ts.map +1 -1
  43. package/dist/types/cwe/cwe-decrypt.d.ts +2 -2
  44. package/dist/types/cwe/cwe-decrypt.d.ts.map +1 -1
  45. package/dist/types/cwe/cwe-encrypt.d.ts +2 -0
  46. package/dist/types/cwe/cwe-encrypt.d.ts.map +1 -1
  47. package/dist/types/cws/cws-sign.d.ts +2 -0
  48. package/dist/types/cws/cws-sign.d.ts.map +1 -1
  49. package/dist/types/cwt/cwt-sign.d.ts +4 -1
  50. package/dist/types/cwt/cwt-sign.d.ts.map +1 -1
  51. package/dist/types/header.d.ts +6 -10
  52. package/dist/types/header.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +1 -1
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/types/index.js +1 -1
  56. package/dist/types/index.js.map +1 -1
  57. package/dist/types/jwt/jwt-validate.d.ts +21 -21
  58. package/dist/types/jwt/jwt-validate.d.ts.map +1 -1
  59. package/dist/types/jwt/jwt-verify.d.ts +21 -21
  60. package/dist/types/jwt/jwt-verify.d.ts.map +1 -1
  61. package/dist/utils/private/auth-tag-length.js.map +1 -1
  62. package/dist/utils/private/cose/claims.d.ts +3 -3
  63. package/dist/utils/private/cose/claims.d.ts.map +1 -1
  64. package/dist/utils/private/cose/claims.js +27 -5
  65. package/dist/utils/private/cose/claims.js.map +1 -1
  66. package/dist/utils/private/cose/header.d.ts +3 -3
  67. package/dist/utils/private/cose/header.d.ts.map +1 -1
  68. package/dist/utils/private/cose/header.js +19 -26
  69. package/dist/utils/private/cose/header.js.map +1 -1
  70. package/dist/utils/private/cose/key.d.ts +1 -1
  71. package/dist/utils/private/cose/key.d.ts.map +1 -1
  72. package/dist/utils/private/cose/key.js +16 -12
  73. package/dist/utils/private/cose/key.js.map +1 -1
  74. package/dist/utils/private/cose-sign-token.d.ts +1 -2
  75. package/dist/utils/private/cose-sign-token.d.ts.map +1 -1
  76. package/dist/utils/private/cose-sign-token.js.map +1 -1
  77. package/dist/utils/private/index.d.ts +0 -1
  78. package/dist/utils/private/index.d.ts.map +1 -1
  79. package/dist/utils/private/index.js +0 -1
  80. package/dist/utils/private/index.js.map +1 -1
  81. package/dist/utils/private/jose-header.d.ts.map +1 -1
  82. package/dist/utils/private/jose-header.js +12 -17
  83. package/dist/utils/private/jose-header.js.map +1 -1
  84. package/dist/utils/private/jwt-validate.d.ts +3 -3
  85. package/dist/utils/private/jwt-validate.d.ts.map +1 -1
  86. package/dist/utils/private/jwt-validate.js +9 -9
  87. package/dist/utils/private/jwt-validate.js.map +1 -1
  88. package/dist/utils/private/jwt-verify.d.ts +3 -3
  89. package/dist/utils/private/jwt-verify.d.ts.map +1 -1
  90. package/dist/utils/private/jwt-verify.js +14 -14
  91. package/dist/utils/private/jwt-verify.js.map +1 -1
  92. package/dist/utils/private/token-header.d.ts.map +1 -1
  93. package/dist/utils/private/token-header.js +2 -10
  94. package/dist/utils/private/token-header.js.map +1 -1
  95. package/dist/utils/private/validate.d.ts +2 -3
  96. package/dist/utils/private/validate.d.ts.map +1 -1
  97. package/dist/utils/private/validate.js +9 -10
  98. package/dist/utils/private/validate.js.map +1 -1
  99. package/jest.config.interop.mjs +27 -0
  100. package/package.json +26 -25
  101. package/tsconfig.interop.json +9 -0
  102. package/dist/types/operators.d.ts +0 -27
  103. package/dist/types/operators.d.ts.map +0 -1
  104. package/dist/types/operators.js.map +0 -1
  105. package/dist/utils/private/validate-value.d.ts +0 -3
  106. package/dist/utils/private/validate-value.d.ts.map +0 -1
  107. package/dist/utils/private/validate-value.js +0 -91
  108. package/dist/utils/private/validate-value.js.map +0 -1
@@ -0,0 +1,1127 @@
1
+ import { KeyObject } from "crypto";
2
+ import { KryptosKit } from "@lindorm/kryptos";
3
+ import { createMockLogger } from "@lindorm/logger";
4
+ import { importJWK } from "jose";
5
+ import { Encoder } from "cbor-x";
6
+ import {
7
+ Sign1,
8
+ Algorithms,
9
+ Headers,
10
+ ProtectedHeaders,
11
+ UnprotectedHeaders,
12
+ } from "@auth0/cose";
13
+ import { encode as cborEncode } from "cbor";
14
+ import { CwsKit } from "../src/classes/CwsKit";
15
+ import { CweKit } from "../src/classes/CweKit";
16
+ import { CwtKit } from "../src/classes/CwtKit";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // cbor-x encoder configured identically to @auth0/cose (Maps, not objects)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const cborEncoder = new Encoder({
23
+ tagUint8Array: false,
24
+ useRecords: false,
25
+ mapsAsObjects: false,
26
+ } as any);
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Shared constants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const PLAINTEXT = "hello aegis cose interop";
33
+ const ISSUER = "https://cose-interop.test.lindorm.io/";
34
+ const SUBJECT = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d";
35
+ const logger = createMockLogger();
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Key generation helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const createEcSigKey = () =>
42
+ KryptosKit.generate.sig.ec({ algorithm: "ES256", curve: "P-256" });
43
+
44
+ const createOkpSigKey = () =>
45
+ KryptosKit.generate.sig.okp({ algorithm: "EdDSA", curve: "Ed25519" });
46
+
47
+ const createRsaSigKey = () => KryptosKit.generate.sig.rsa({ algorithm: "RS256" });
48
+
49
+ const createOctDirKey = () =>
50
+ KryptosKit.generate.enc.oct({ algorithm: "dir", encryption: "A256GCM" });
51
+
52
+ const createOctKwKey = () => KryptosKit.generate.enc.oct({ algorithm: "A128KW" });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Helper: export public-only JWK for jose/auth0 verification
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const toPublicJwk = (jwk: Record<string, unknown>): Record<string, unknown> => {
59
+ const { d, dp, dq, p, q, qi, k, ...publicParts } = jwk as any;
60
+ return publicParts;
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helper: get jose KeyLike from kryptos (public key for verify, private for sign)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const getJoseKey = async (
68
+ kryptos: ReturnType<typeof createEcSigKey>,
69
+ mode: "public" | "private" = "public",
70
+ ): Promise<CryptoKey | KeyObject> => {
71
+ const jwk = kryptos.export("jwk");
72
+ const keyJwk = mode === "public" ? toPublicJwk(jwk) : jwk;
73
+ return (await importJWK(keyJwk, jwk.alg)) as CryptoKey | KeyObject;
74
+ };
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // COSE label constants (from RFC 9052 / aegis COSE_HEADER/COSE_CLAIMS)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const COSE_LABEL = {
81
+ ALG: 1,
82
+ CRIT: 2,
83
+ CTY: 3,
84
+ KID: 4,
85
+ IV: 5,
86
+ TYP: 16,
87
+ OID: 400,
88
+ } as const;
89
+
90
+ const COSE_CLAIM = {
91
+ ISS: 1,
92
+ SUB: 2,
93
+ AUD: 3,
94
+ EXP: 4,
95
+ NBF: 5,
96
+ IAT: 6,
97
+ JTI: 7, // cti in COSE, mapped to jti in aegis
98
+ } as const;
99
+
100
+ // Map from aegis algorithm string to COSE integer label
101
+ const COSE_ALG_LABEL: Record<string, number> = {
102
+ ES256: -7,
103
+ ES384: -35,
104
+ ES512: -36,
105
+ EdDSA: -8,
106
+ PS256: -37,
107
+ PS384: -38,
108
+ PS512: -39,
109
+ RS256: -257,
110
+ RS384: -258,
111
+ RS512: -259,
112
+ HS256: 5,
113
+ HS384: 6,
114
+ HS512: 7,
115
+ A128GCM: 1,
116
+ A192GCM: 2,
117
+ A256GCM: 3,
118
+ dir: -6,
119
+ A128KW: -3,
120
+ A192KW: -4,
121
+ A256KW: -5,
122
+ "ECDH-ES": -25,
123
+ };
124
+
125
+ // ===========================================================================
126
+ // CWS (COSE Sign1) structural compliance tests
127
+ // ===========================================================================
128
+
129
+ describe("COSE interop: CWS structural compliance", () => {
130
+ describe.each([
131
+ { name: "EC / ES256", createKey: createEcSigKey, algLabel: -7 },
132
+ { name: "OKP / EdDSA", createKey: createOkpSigKey, algLabel: -8 },
133
+ { name: "RSA / RS256", createKey: createRsaSigKey, algLabel: -257 },
134
+ ])("$name", ({ createKey, algLabel }) => {
135
+ test("aegis CWS token decodes as valid COSE Sign1 4-tuple", () => {
136
+ const kryptos = createKey();
137
+ const kit = new CwsKit({ logger, kryptos });
138
+
139
+ const { buffer } = kit.sign(PLAINTEXT);
140
+
141
+ // Decode the raw CBOR with cbor-x (not the mock)
142
+ const decoded = cborEncoder.decode(buffer);
143
+
144
+ // Must be a 4-element array: [protectedCbor, unprotectedMap, payload, signature]
145
+ expect(Array.isArray(decoded)).toBe(true);
146
+ expect(decoded).toHaveLength(4);
147
+
148
+ const [protectedCbor, unprotectedMap, payloadCbor, signature] = decoded;
149
+
150
+ // protectedCbor must be a Uint8Array/Buffer (CBOR bstr)
151
+ expect(Buffer.isBuffer(protectedCbor) || protectedCbor instanceof Uint8Array).toBe(
152
+ true,
153
+ );
154
+
155
+ // unprotectedMap must be a Map (decoded with mapsAsObjects: false)
156
+ expect(unprotectedMap).toBeInstanceOf(Map);
157
+
158
+ // payloadCbor must be a Uint8Array/Buffer
159
+ expect(Buffer.isBuffer(payloadCbor) || payloadCbor instanceof Uint8Array).toBe(
160
+ true,
161
+ );
162
+
163
+ // signature must be a Uint8Array/Buffer
164
+ expect(Buffer.isBuffer(signature) || signature instanceof Uint8Array).toBe(true);
165
+ expect(signature.length).toBeGreaterThan(0);
166
+ });
167
+
168
+ test("protected header has correct COSE label mappings", () => {
169
+ const kryptos = createKey();
170
+ const kit = new CwsKit({ logger, kryptos });
171
+
172
+ const { buffer } = kit.sign(PLAINTEXT);
173
+
174
+ const decoded = cborEncoder.decode(buffer);
175
+ const [protectedCbor] = decoded;
176
+
177
+ // Decode the protected header bytes
178
+ const protectedMap: Map<number, unknown> = cborEncoder.decode(protectedCbor);
179
+ expect(protectedMap).toBeInstanceOf(Map);
180
+
181
+ // Label 1 = algorithm (must match the expected COSE algorithm label)
182
+ expect(protectedMap.get(COSE_LABEL.ALG)).toBe(algLabel);
183
+
184
+ // Label 3 = content type
185
+ expect(protectedMap.get(COSE_LABEL.CTY)).toBe("text/plain; charset=utf-8");
186
+
187
+ // Label 16 = type
188
+ expect(protectedMap.get(COSE_LABEL.TYP)).toBe(
189
+ "application/cose; cose-type=cose-sign",
190
+ );
191
+ });
192
+
193
+ test("unprotected header contains kid and oid labels", () => {
194
+ const kryptos = createKey();
195
+ const kit = new CwsKit({ logger, kryptos });
196
+
197
+ const { buffer, objectId } = kit.sign(PLAINTEXT, {
198
+ objectId: "test-object-id-123",
199
+ });
200
+
201
+ const decoded = cborEncoder.decode(buffer);
202
+ const [, unprotectedMap] = decoded;
203
+
204
+ expect(unprotectedMap).toBeInstanceOf(Map);
205
+
206
+ // Label 4 = kid (COSE KID is bstr, aegis encodes as Buffer)
207
+ const kidValue = unprotectedMap.get(COSE_LABEL.KID);
208
+ expect(kidValue).toBeDefined();
209
+ // The kid is a Buffer containing the kryptos.id string
210
+ const kidStr =
211
+ kidValue instanceof Uint8Array
212
+ ? Buffer.from(kidValue).toString("utf8")
213
+ : kidValue;
214
+ expect(kidStr).toBe(kryptos.id);
215
+
216
+ // Label 400 = oid (Lindorm extension)
217
+ const oidValue = unprotectedMap.get(COSE_LABEL.OID);
218
+ expect(oidValue).toBeDefined();
219
+ const oidStr =
220
+ oidValue instanceof Uint8Array
221
+ ? Buffer.from(oidValue).toString("utf8")
222
+ : oidValue;
223
+ expect(oidStr).toBe(objectId);
224
+ });
225
+
226
+ test("signature is computed over RFC 9052 Sig_structure", () => {
227
+ const kryptos = createKey();
228
+ const kit = new CwsKit({ logger, kryptos });
229
+
230
+ const { buffer } = kit.sign(PLAINTEXT);
231
+
232
+ const decoded = cborEncoder.decode(buffer);
233
+ const [protectedCbor, , payloadCbor, signature] = decoded;
234
+
235
+ // Reconstruct the Sig_structure that aegis should have signed:
236
+ // ["Signature1", protectedCbor, external_aad (empty), payload]
237
+ const sigStructure = cborEncoder.encode([
238
+ "Signature1",
239
+ protectedCbor,
240
+ new Uint8Array(0),
241
+ payloadCbor,
242
+ ]);
243
+
244
+ // We can't verify the signature here without reimplementing crypto,
245
+ // but we verify the structure exists and the token round-trips through aegis
246
+ expect(sigStructure).toBeInstanceOf(Uint8Array);
247
+ expect(sigStructure.length).toBeGreaterThan(0);
248
+
249
+ // Verify aegis can round-trip its own token
250
+ const result = kit.verify(buffer);
251
+ expect(result.payload).toBe(PLAINTEXT);
252
+ });
253
+ });
254
+ });
255
+
256
+ // ===========================================================================
257
+ // CWS (COSE Sign1) interop: aegis <-> @auth0/cose
258
+ // ===========================================================================
259
+
260
+ describe("COSE interop: aegis CWS <-> @auth0/cose Sign1", () => {
261
+ describe.each([
262
+ {
263
+ name: "EC / ES256",
264
+ createKey: createEcSigKey,
265
+ auth0Alg: Algorithms.ES256,
266
+ },
267
+ {
268
+ name: "OKP / EdDSA",
269
+ createKey: createOkpSigKey,
270
+ auth0Alg: Algorithms.EdDSA,
271
+ },
272
+ {
273
+ name: "RSA / RS256",
274
+ createKey: createRsaSigKey,
275
+ auth0Alg: Algorithms.RS256,
276
+ },
277
+ ])("$name", ({ createKey, auth0Alg }) => {
278
+ test("aegis sign -> @auth0/cose decode + verify", async () => {
279
+ const kryptos = createKey();
280
+ const kit = new CwsKit({ logger, kryptos });
281
+
282
+ const { buffer } = kit.sign(PLAINTEXT);
283
+
284
+ // Decode using @auth0/cose
285
+ const sign1 = Sign1.decode(buffer);
286
+
287
+ // Verify protected header algorithm matches
288
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(auth0Alg);
289
+
290
+ // The payload field should be the CBOR-encoded payload bytes
291
+ expect(sign1.payload).toBeInstanceOf(Uint8Array);
292
+ expect(sign1.payload.length).toBeGreaterThan(0);
293
+
294
+ // Verify the signature using jose KeyLike
295
+ const publicKey = await getJoseKey(kryptos, "public");
296
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
297
+ });
298
+
299
+ test("@auth0/cose sign -> aegis verify", async () => {
300
+ const kryptos = createKey();
301
+ const kit = new CwsKit({ logger, kryptos });
302
+
303
+ // Get jose private key for @auth0/cose signing
304
+ const privateKey = await getJoseKey(kryptos, "private");
305
+
306
+ // Build COSE protected and unprotected headers matching aegis format.
307
+ // We use ProtectedHeaders/UnprotectedHeaders with `as any` to include
308
+ // Lindorm-proprietary labels (16=typ, 400=oid) that aren't in @auth0/cose's enum.
309
+ const protectedHeaders = new ProtectedHeaders([
310
+ [Headers.Algorithm, auth0Alg],
311
+ [Headers.ContentType, "text/plain; charset=utf-8"],
312
+ [COSE_LABEL.TYP, "application/cose; cose-type=cose-sign"],
313
+ ] as any);
314
+
315
+ const unprotectedHeaders = new UnprotectedHeaders([
316
+ [Headers.KeyID, Buffer.from(kryptos.id, "utf-8")],
317
+ [COSE_LABEL.OID, Buffer.from("test-oid", "utf-8")],
318
+ ] as any);
319
+
320
+ // aegis expects payloadCbor = cbor.encode(payloadBuffer) as the Sign1 payload
321
+ // We need to CBOR-encode the payload buffer to match aegis's double-encoding
322
+ const payloadBuffer = Buffer.from(PLAINTEXT, "utf-8");
323
+ const payloadCbor = cborEncoder.encode(payloadBuffer);
324
+
325
+ const sign1 = await Sign1.sign(
326
+ protectedHeaders,
327
+ unprotectedHeaders,
328
+ payloadCbor,
329
+ privateKey,
330
+ );
331
+
332
+ // @auth0/cose Sign1.encode() produces tagged CBOR (tag 18).
333
+ // aegis expects untagged CBOR arrays. Extract the raw components
334
+ // via getContentForEncoding() and re-encode as an untagged array.
335
+ const components = sign1.getContentForEncoding();
336
+ const tokenBytes = cborEncode(components);
337
+
338
+ // Verify with aegis
339
+ const result = kit.verify(tokenBytes);
340
+ expect(result.payload).toBe(PLAINTEXT);
341
+ expect(result.header.algorithm).toBe(kryptos.algorithm);
342
+ expect(result.header.contentType).toBe("text/plain; charset=utf-8");
343
+ expect(result.header.headerType).toBe("application/cose; cose-type=cose-sign");
344
+ });
345
+ });
346
+ });
347
+
348
+ // ===========================================================================
349
+ // CWE (COSE Encrypt) structural compliance tests
350
+ // ===========================================================================
351
+
352
+ describe("COSE interop: CWE structural compliance", () => {
353
+ test("aegis CWE token decodes as valid 4-tuple", () => {
354
+ const kryptos = createOctDirKey();
355
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
356
+
357
+ const { buffer } = kit.encrypt(PLAINTEXT);
358
+
359
+ const decoded = cborEncoder.decode(buffer);
360
+
361
+ // Must be a 4-element array: [protectedCbor, unprotectedMap, ciphertext, recipients]
362
+ expect(Array.isArray(decoded)).toBe(true);
363
+ expect(decoded).toHaveLength(4);
364
+
365
+ const [protectedCbor, unprotectedMap, ciphertext, recipients] = decoded;
366
+
367
+ // protectedCbor must be bstr
368
+ expect(Buffer.isBuffer(protectedCbor) || protectedCbor instanceof Uint8Array).toBe(
369
+ true,
370
+ );
371
+
372
+ // unprotectedMap must be a Map
373
+ expect(unprotectedMap).toBeInstanceOf(Map);
374
+
375
+ // ciphertext must be bstr
376
+ expect(Buffer.isBuffer(ciphertext) || ciphertext instanceof Uint8Array).toBe(true);
377
+ expect(ciphertext.length).toBeGreaterThan(0);
378
+
379
+ // recipients must be an array
380
+ expect(Array.isArray(recipients)).toBe(true);
381
+ expect(recipients.length).toBeGreaterThanOrEqual(1);
382
+ });
383
+
384
+ test("protected header label 1 = content encryption algorithm (A256GCM -> 3)", () => {
385
+ const kryptos = createOctDirKey();
386
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
387
+
388
+ const { buffer } = kit.encrypt(PLAINTEXT);
389
+
390
+ const decoded = cborEncoder.decode(buffer);
391
+ const [protectedCbor] = decoded;
392
+
393
+ const protectedMap: Map<number, unknown> = cborEncoder.decode(protectedCbor);
394
+ expect(protectedMap).toBeInstanceOf(Map);
395
+
396
+ // RFC 9052: protected header alg = content encryption algorithm
397
+ // A256GCM = label 3
398
+ expect(protectedMap.get(COSE_LABEL.ALG)).toBe(COSE_ALG_LABEL["A256GCM"]);
399
+
400
+ // Also verify typ and cty
401
+ expect(protectedMap.get(COSE_LABEL.TYP)).toBe(
402
+ "application/cose; cose-type=cose-encrypt",
403
+ );
404
+ expect(protectedMap.get(COSE_LABEL.CTY)).toBe("text/plain");
405
+ });
406
+
407
+ test("recipient header label 1 = key management algorithm", () => {
408
+ const kryptos = createOctKwKey();
409
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
410
+
411
+ const { buffer } = kit.encrypt(PLAINTEXT);
412
+
413
+ const decoded = cborEncoder.decode(buffer);
414
+ const [, , , recipients] = decoded;
415
+
416
+ // First recipient is [protectedCbor, recipientHeaderMap, publicEncryptionKey]
417
+ const [recipient] = recipients;
418
+ expect(Array.isArray(recipient)).toBe(true);
419
+ expect(recipient.length).toBe(3);
420
+
421
+ const [, recipientHeaderMap] = recipient;
422
+ expect(recipientHeaderMap).toBeInstanceOf(Map);
423
+
424
+ // Recipient header label 1 = key management algorithm (A128KW = -3)
425
+ expect(recipientHeaderMap.get(COSE_LABEL.ALG)).toBe(COSE_ALG_LABEL["A128KW"]);
426
+
427
+ // Recipient header label 4 = kid
428
+ const kidValue = recipientHeaderMap.get(COSE_LABEL.KID);
429
+ expect(kidValue).toBeDefined();
430
+ const kidStr =
431
+ kidValue instanceof Uint8Array ? Buffer.from(kidValue).toString("utf8") : kidValue;
432
+ expect(kidStr).toBe(kryptos.id);
433
+ });
434
+
435
+ test("ciphertext contains content + auth tag (GCM)", () => {
436
+ const kryptos = createOctDirKey();
437
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
438
+
439
+ const { buffer } = kit.encrypt(PLAINTEXT);
440
+
441
+ const decoded = cborEncoder.decode(buffer);
442
+ const [, , ciphertext] = decoded;
443
+
444
+ // GCM auth tag is 16 bytes (128 bits), so ciphertext must be longer than 16 bytes
445
+ // (actual content + 16 byte tag)
446
+ expect(ciphertext.length).toBeGreaterThan(16);
447
+ });
448
+
449
+ test("unprotected header contains iv label", () => {
450
+ const kryptos = createOctDirKey();
451
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
452
+
453
+ const { buffer } = kit.encrypt(PLAINTEXT);
454
+
455
+ const decoded = cborEncoder.decode(buffer);
456
+ const [, unprotectedMap] = decoded;
457
+
458
+ // Label 5 = iv
459
+ const iv = unprotectedMap.get(COSE_LABEL.IV);
460
+ expect(iv).toBeDefined();
461
+ expect(iv instanceof Uint8Array || Buffer.isBuffer(iv)).toBe(true);
462
+ // GCM IV is 12 bytes
463
+ expect(iv.length).toBe(12);
464
+ });
465
+
466
+ test("CWE round-trip: encrypt then decrypt produces original plaintext", () => {
467
+ const kryptos = createOctDirKey();
468
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
469
+
470
+ const { token } = kit.encrypt(PLAINTEXT);
471
+ const result = kit.decrypt(token);
472
+
473
+ expect(result.payload).toBe(PLAINTEXT);
474
+ // In COSE format, the protected header alg = content encryption algorithm
475
+ // This is mapped to header.algorithm (not header.encryption)
476
+ expect(result.header.algorithm).toBe("A256GCM");
477
+ });
478
+
479
+ test("CWE with A128KW round-trips correctly", () => {
480
+ const kryptos = createOctKwKey();
481
+ const kit = new CweKit({ logger, kryptos, encryption: "A128GCM" });
482
+
483
+ const { token } = kit.encrypt(PLAINTEXT);
484
+ const result = kit.decrypt(token);
485
+
486
+ expect(result.payload).toBe(PLAINTEXT);
487
+ // COSE: protected alg = content encryption (A128GCM), recipient alg = key management (A128KW)
488
+ // parseTokenHeader maps protected alg to header.algorithm
489
+ expect(result.header.algorithm).toBe("A128GCM");
490
+ });
491
+ });
492
+
493
+ // ===========================================================================
494
+ // CWT claim label compliance tests
495
+ // ===========================================================================
496
+
497
+ describe("COSE interop: CWT claim labels (RFC 8392)", () => {
498
+ test("CWT payload uses standard integer claim labels", () => {
499
+ const kryptos = createEcSigKey();
500
+ const kit = new CwtKit({
501
+ issuer: ISSUER,
502
+ logger,
503
+ kryptos,
504
+ });
505
+
506
+ const { buffer } = kit.sign({
507
+ expires: "1h",
508
+ subject: SUBJECT,
509
+ tokenType: "access_token",
510
+ });
511
+
512
+ // Decode the outer CBOR array
513
+ const decoded = cborEncoder.decode(buffer);
514
+ expect(Array.isArray(decoded)).toBe(true);
515
+ expect(decoded).toHaveLength(4);
516
+
517
+ const [, , payloadCbor] = decoded;
518
+
519
+ // Decode the payload CBOR map
520
+ const payloadMap: Map<number, unknown> = cborEncoder.decode(payloadCbor);
521
+ expect(payloadMap).toBeInstanceOf(Map);
522
+
523
+ // RFC 8392 claim labels:
524
+ // Label 1 = iss
525
+ const issValue = payloadMap.get(COSE_CLAIM.ISS);
526
+ expect(issValue).toBe(ISSUER);
527
+
528
+ // Label 2 = sub
529
+ const subValue = payloadMap.get(COSE_CLAIM.SUB);
530
+ expect(subValue).toBeDefined();
531
+ // sub is stored as bstr in aegis, decode it
532
+ const subStr =
533
+ subValue instanceof Uint8Array ? Buffer.from(subValue).toString("utf8") : subValue;
534
+ expect(subStr).toBe(SUBJECT);
535
+
536
+ // Label 4 = exp
537
+ const expValue = payloadMap.get(COSE_CLAIM.EXP);
538
+ expect(typeof expValue).toBe("number");
539
+ expect(expValue).toBeGreaterThan(0);
540
+
541
+ // Label 6 = iat
542
+ const iatValue = payloadMap.get(COSE_CLAIM.IAT);
543
+ expect(typeof iatValue).toBe("number");
544
+ expect(iatValue).toBeGreaterThan(0);
545
+
546
+ // Label 7 = jti (cti in COSE, mapped from jti)
547
+ const jtiValue = payloadMap.get(COSE_CLAIM.JTI);
548
+ expect(jtiValue).toBeDefined();
549
+ });
550
+
551
+ test("CWT protected header has correct algorithm label", () => {
552
+ const kryptos = createEcSigKey();
553
+ const kit = new CwtKit({
554
+ issuer: ISSUER,
555
+ logger,
556
+ kryptos,
557
+ });
558
+
559
+ const { buffer } = kit.sign({
560
+ expires: "1h",
561
+ subject: SUBJECT,
562
+ tokenType: "access_token",
563
+ });
564
+
565
+ const decoded = cborEncoder.decode(buffer);
566
+ const [protectedCbor] = decoded;
567
+
568
+ const protectedMap: Map<number, unknown> = cborEncoder.decode(protectedCbor);
569
+ expect(protectedMap).toBeInstanceOf(Map);
570
+
571
+ // Algorithm label 1 = ES256 = -7
572
+ expect(protectedMap.get(COSE_LABEL.ALG)).toBe(COSE_ALG_LABEL["ES256"]);
573
+
574
+ // Type label 16
575
+ expect(protectedMap.get(COSE_LABEL.TYP)).toBe("application/cwt");
576
+
577
+ // Content type label 3
578
+ expect(protectedMap.get(COSE_LABEL.CTY)).toBe("application/json");
579
+ });
580
+
581
+ test("CWT with OKP/EdDSA verifiable by @auth0/cose Sign1", async () => {
582
+ const kryptos = createOkpSigKey();
583
+ const kit = new CwtKit({
584
+ issuer: ISSUER,
585
+ logger,
586
+ kryptos,
587
+ });
588
+
589
+ const { buffer } = kit.sign({
590
+ expires: "1h",
591
+ subject: SUBJECT,
592
+ tokenType: "access_token",
593
+ });
594
+
595
+ // @auth0/cose should be able to decode and verify the CWT
596
+ // since CWT uses the same COSE_Sign1 structure as CWS
597
+ const sign1 = Sign1.decode(buffer);
598
+
599
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.EdDSA);
600
+
601
+ const publicKey = await getJoseKey(kryptos, "public");
602
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
603
+ });
604
+
605
+ test("CWT with EC/ES256 verifiable by @auth0/cose Sign1", async () => {
606
+ const kryptos = createEcSigKey();
607
+ const kit = new CwtKit({
608
+ issuer: ISSUER,
609
+ logger,
610
+ kryptos,
611
+ });
612
+
613
+ const { buffer } = kit.sign({
614
+ expires: "1h",
615
+ subject: SUBJECT,
616
+ tokenType: "access_token",
617
+ });
618
+
619
+ const sign1 = Sign1.decode(buffer);
620
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.ES256);
621
+
622
+ const publicKey = await getJoseKey(kryptos, "public");
623
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
624
+ });
625
+
626
+ test("CWT with RSA/RS256 verifiable by @auth0/cose Sign1", async () => {
627
+ const kryptos = createRsaSigKey();
628
+ const kit = new CwtKit({
629
+ issuer: ISSUER,
630
+ logger,
631
+ kryptos,
632
+ });
633
+
634
+ const { buffer } = kit.sign({
635
+ expires: "1h",
636
+ subject: SUBJECT,
637
+ tokenType: "access_token",
638
+ });
639
+
640
+ const sign1 = Sign1.decode(buffer);
641
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.RS256);
642
+
643
+ const publicKey = await getJoseKey(kryptos, "public");
644
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
645
+ });
646
+
647
+ test("CWT round-trip through aegis sign + verify", () => {
648
+ const kryptos = createEcSigKey();
649
+ const kit = new CwtKit({
650
+ issuer: ISSUER,
651
+ logger,
652
+ kryptos,
653
+ });
654
+
655
+ const { token } = kit.sign({
656
+ expires: "1h",
657
+ subject: SUBJECT,
658
+ tokenType: "access_token",
659
+ });
660
+
661
+ const result = kit.verify(token);
662
+ expect(result.payload.issuer).toBe(ISSUER);
663
+ expect(result.payload.subject).toBe(SUBJECT);
664
+ expect(result.payload.tokenType).toBe("access_token");
665
+ expect(result.payload.expiresAt).toBeInstanceOf(Date);
666
+ });
667
+ });
668
+
669
+ // ===========================================================================
670
+ // Algorithm label mapping completeness
671
+ // ===========================================================================
672
+
673
+ describe("COSE interop: algorithm label mappings match RFC 9053", () => {
674
+ test.each([
675
+ { alg: "ES256", label: -7 },
676
+ { alg: "ES384", label: -35 },
677
+ { alg: "ES512", label: -36 },
678
+ { alg: "EdDSA", label: -8 },
679
+ { alg: "RS256", label: -257 },
680
+ { alg: "RS384", label: -258 },
681
+ { alg: "RS512", label: -259 },
682
+ { alg: "PS256", label: -37 },
683
+ { alg: "PS384", label: -38 },
684
+ { alg: "PS512", label: -39 },
685
+ { alg: "HS256", label: 5 },
686
+ { alg: "HS384", label: 6 },
687
+ { alg: "HS512", label: 7 },
688
+ { alg: "A128GCM", label: 1 },
689
+ { alg: "A192GCM", label: 2 },
690
+ { alg: "A256GCM", label: 3 },
691
+ ])("aegis maps $alg to COSE label $label", ({ alg, label }) => {
692
+ expect(COSE_ALG_LABEL[alg]).toBe(label);
693
+ });
694
+
695
+ // Verify aegis signature algorithms match @auth0/cose enum values
696
+ test.each([
697
+ { alg: "EdDSA", auth0Value: Algorithms.EdDSA },
698
+ { alg: "ES256", auth0Value: Algorithms.ES256 },
699
+ { alg: "ES384", auth0Value: Algorithms.ES384 },
700
+ { alg: "ES512", auth0Value: Algorithms.ES512 },
701
+ { alg: "RS256", auth0Value: Algorithms.RS256 },
702
+ { alg: "RS384", auth0Value: Algorithms.RS384 },
703
+ { alg: "RS512", auth0Value: Algorithms.RS512 },
704
+ { alg: "PS256", auth0Value: Algorithms.PS256 },
705
+ { alg: "PS384", auth0Value: Algorithms.PS384 },
706
+ { alg: "PS512", auth0Value: Algorithms.PS512 },
707
+ ])("aegis $alg label matches @auth0/cose Algorithms.$alg", ({ alg, auth0Value }) => {
708
+ expect(COSE_ALG_LABEL[alg]).toBe(auth0Value);
709
+ });
710
+ });
711
+
712
+ // ===========================================================================
713
+ // COSE header label mappings match RFC 9052
714
+ // ===========================================================================
715
+
716
+ describe("COSE interop: header label mappings match RFC 9052", () => {
717
+ test.each([
718
+ { header: "Algorithm", aegisLabel: COSE_LABEL.ALG, auth0Value: Headers.Algorithm },
719
+ { header: "Critical", aegisLabel: COSE_LABEL.CRIT, auth0Value: Headers.Critical },
720
+ {
721
+ header: "ContentType",
722
+ aegisLabel: COSE_LABEL.CTY,
723
+ auth0Value: Headers.ContentType,
724
+ },
725
+ { header: "KeyID", aegisLabel: COSE_LABEL.KID, auth0Value: Headers.KeyID },
726
+ { header: "IV", aegisLabel: COSE_LABEL.IV, auth0Value: Headers.IV },
727
+ ])(
728
+ "aegis label for $header matches @auth0/cose Headers.$header",
729
+ ({ aegisLabel, auth0Value }) => {
730
+ expect(aegisLabel).toBe(auth0Value);
731
+ },
732
+ );
733
+ });
734
+
735
+ // ===========================================================================
736
+ // External target mode: proprietary labels as string keys
737
+ // ===========================================================================
738
+
739
+ describe("COSE interop: external target mode", () => {
740
+ describe("CWS with target: external", () => {
741
+ test("no integer labels >= 400 in unprotected header", () => {
742
+ const kryptos = createEcSigKey();
743
+ const kit = new CwsKit({ logger, kryptos });
744
+
745
+ const { buffer } = kit.sign(PLAINTEXT, {
746
+ objectId: "ext-test-oid",
747
+ target: "external",
748
+ });
749
+
750
+ const decoded = cborEncoder.decode(buffer);
751
+ const [, unprotectedMap] = decoded;
752
+
753
+ expect(unprotectedMap).toBeInstanceOf(Map);
754
+
755
+ // No integer keys >= 400 should exist
756
+ for (const key of unprotectedMap.keys()) {
757
+ if (typeof key === "number") {
758
+ expect(key).toBeLessThan(400);
759
+ }
760
+ }
761
+
762
+ // oid should be present as string key with raw value (not bstr)
763
+ expect(unprotectedMap.get("oid")).toBe("ext-test-oid");
764
+ });
765
+
766
+ test("standard RFC labels still use integers", () => {
767
+ const kryptos = createEcSigKey();
768
+ const kit = new CwsKit({ logger, kryptos });
769
+
770
+ const { buffer } = kit.sign(PLAINTEXT, { target: "external" });
771
+
772
+ const decoded = cborEncoder.decode(buffer);
773
+ const [protectedCbor, unprotectedMap] = decoded;
774
+
775
+ // Protected header still has integer labels
776
+ const protectedMap: Map<number, unknown> = cborEncoder.decode(protectedCbor);
777
+ expect(protectedMap.get(COSE_LABEL.ALG)).toBe(COSE_ALG_LABEL["ES256"]);
778
+ expect(protectedMap.get(COSE_LABEL.CTY)).toBe("text/plain; charset=utf-8");
779
+ expect(protectedMap.get(COSE_LABEL.TYP)).toBe(
780
+ "application/cose; cose-type=cose-sign",
781
+ );
782
+
783
+ // Unprotected header: kid still uses integer label 4
784
+ const kidValue = unprotectedMap.get(COSE_LABEL.KID);
785
+ expect(kidValue).toBeDefined();
786
+ });
787
+
788
+ test("aegis sign (external) -> @auth0/cose decode + verify", async () => {
789
+ const kryptos = createEcSigKey();
790
+ const kit = new CwsKit({ logger, kryptos });
791
+
792
+ const { buffer } = kit.sign(PLAINTEXT, { target: "external" });
793
+
794
+ const sign1 = Sign1.decode(buffer);
795
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.ES256);
796
+
797
+ const publicKey = await getJoseKey(kryptos, "public");
798
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
799
+ });
800
+
801
+ test("round-trip: external CWS sign -> aegis verify", () => {
802
+ const kryptos = createEcSigKey();
803
+ const kit = new CwsKit({ logger, kryptos });
804
+
805
+ const { buffer } = kit.sign(PLAINTEXT, {
806
+ objectId: "roundtrip-ext",
807
+ target: "external",
808
+ });
809
+
810
+ // Decode path handles both formats
811
+ const result = kit.verify(buffer);
812
+ expect(result.payload).toBe(PLAINTEXT);
813
+ expect(result.header.objectId).toBe("roundtrip-ext");
814
+ });
815
+ });
816
+
817
+ describe("CWE with target: external", () => {
818
+ test("no integer labels >= 400 in unprotected header", () => {
819
+ const kryptos = createOctDirKey();
820
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
821
+
822
+ const { buffer } = kit.encrypt(PLAINTEXT, {
823
+ objectId: "cwe-ext-oid",
824
+ target: "external",
825
+ });
826
+
827
+ const decoded = cborEncoder.decode(buffer);
828
+ const [, unprotectedMap] = decoded;
829
+
830
+ expect(unprotectedMap).toBeInstanceOf(Map);
831
+
832
+ for (const key of unprotectedMap.keys()) {
833
+ if (typeof key === "number") {
834
+ expect(key).toBeLessThan(400);
835
+ }
836
+ }
837
+
838
+ // oid present as string key
839
+ expect(unprotectedMap.get("oid")).toBe("cwe-ext-oid");
840
+ });
841
+
842
+ test("round-trip: external CWE encrypt -> decrypt", () => {
843
+ const kryptos = createOctDirKey();
844
+ const kit = new CweKit({ logger, kryptos, encryption: "A256GCM" });
845
+
846
+ const { token } = kit.encrypt(PLAINTEXT, {
847
+ objectId: "cwe-ext-rt",
848
+ target: "external",
849
+ });
850
+
851
+ const result = kit.decrypt(token);
852
+ expect(result.payload).toBe(PLAINTEXT);
853
+ });
854
+ });
855
+
856
+ describe("CWT with target: external", () => {
857
+ test("no integer labels >= 400 in payload or headers", () => {
858
+ const kryptos = createEcSigKey();
859
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
860
+
861
+ const { buffer } = kit.sign(
862
+ {
863
+ expires: "1h",
864
+ subject: SUBJECT,
865
+ tokenType: "access_token",
866
+ },
867
+ { target: "external" },
868
+ );
869
+
870
+ const decoded = cborEncoder.decode(buffer);
871
+ const [, unprotectedMap, payloadCbor] = decoded;
872
+
873
+ // Unprotected header: no integer keys >= 400
874
+ expect(unprotectedMap).toBeInstanceOf(Map);
875
+ for (const key of unprotectedMap.keys()) {
876
+ if (typeof key === "number") {
877
+ expect(key).toBeLessThan(400);
878
+ }
879
+ }
880
+
881
+ // Payload: no integer keys >= 400
882
+ const payloadMap: Map<number | string, unknown> = cborEncoder.decode(payloadCbor);
883
+ expect(payloadMap).toBeInstanceOf(Map);
884
+ for (const key of payloadMap.keys()) {
885
+ if (typeof key === "number") {
886
+ expect(key).toBeLessThan(400);
887
+ }
888
+ }
889
+
890
+ // Proprietary claims present as string keys
891
+ expect(payloadMap.get("token_type")).toBe("access_token");
892
+ });
893
+
894
+ test("standard CWT claims still use integer labels", () => {
895
+ const kryptos = createEcSigKey();
896
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
897
+
898
+ const { buffer } = kit.sign(
899
+ {
900
+ expires: "1h",
901
+ subject: SUBJECT,
902
+ tokenType: "access_token",
903
+ },
904
+ { target: "external" },
905
+ );
906
+
907
+ const decoded = cborEncoder.decode(buffer);
908
+ const [protectedCbor, , payloadCbor] = decoded;
909
+
910
+ // Protected header: standard labels
911
+ const protectedMap: Map<number, unknown> = cborEncoder.decode(protectedCbor);
912
+ expect(protectedMap.get(COSE_LABEL.ALG)).toBe(COSE_ALG_LABEL["ES256"]);
913
+ expect(protectedMap.get(COSE_LABEL.TYP)).toBe("application/cwt");
914
+
915
+ // Payload: standard claims use integer labels
916
+ const payloadMap: Map<number | string, unknown> = cborEncoder.decode(payloadCbor);
917
+ expect(payloadMap.get(COSE_CLAIM.ISS)).toBe(ISSUER);
918
+ expect(typeof payloadMap.get(COSE_CLAIM.EXP)).toBe("number");
919
+ expect(typeof payloadMap.get(COSE_CLAIM.IAT)).toBe("number");
920
+ });
921
+
922
+ test("aegis CWT (external) verifiable by @auth0/cose Sign1", async () => {
923
+ const kryptos = createEcSigKey();
924
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
925
+
926
+ const { buffer } = kit.sign(
927
+ {
928
+ expires: "1h",
929
+ subject: SUBJECT,
930
+ tokenType: "access_token",
931
+ },
932
+ { target: "external" },
933
+ );
934
+
935
+ const sign1 = Sign1.decode(buffer);
936
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.ES256);
937
+
938
+ const publicKey = await getJoseKey(kryptos, "public");
939
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
940
+ });
941
+
942
+ test("round-trip: external CWT sign -> aegis verify", () => {
943
+ const kryptos = createEcSigKey();
944
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
945
+
946
+ const { token } = kit.sign(
947
+ {
948
+ expires: "1h",
949
+ subject: SUBJECT,
950
+ tokenType: "access_token",
951
+ },
952
+ { target: "external" },
953
+ );
954
+
955
+ const result = kit.verify(token);
956
+ expect(result.payload.issuer).toBe(ISSUER);
957
+ expect(result.payload.subject).toBe(SUBJECT);
958
+ expect(result.payload.tokenType).toBe("access_token");
959
+ });
960
+
961
+ test("decode of external token produces same parsed output as internal", () => {
962
+ const kryptos = createEcSigKey();
963
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
964
+
965
+ const content = {
966
+ expires: "1h",
967
+ subject: SUBJECT,
968
+ tokenType: "access_token",
969
+ } as const;
970
+
971
+ const internal = kit.sign(content);
972
+ const external = kit.sign(content, { target: "external" });
973
+
974
+ const parsedInternal = CwtKit.parse(internal.token);
975
+ const parsedExternal = CwtKit.parse(external.token);
976
+
977
+ // Same parsed payload fields
978
+ expect(parsedExternal.payload.issuer).toBe(parsedInternal.payload.issuer);
979
+ expect(parsedExternal.payload.subject).toBe(parsedInternal.payload.subject);
980
+ expect(parsedExternal.payload.tokenType).toBe(parsedInternal.payload.tokenType);
981
+ expect(parsedExternal.header.algorithm).toBe(parsedInternal.header.algorithm);
982
+ expect(parsedExternal.header.headerType).toBe(parsedInternal.header.headerType);
983
+ });
984
+ });
985
+ });
986
+
987
+ // ===========================================================================
988
+ // Custom COSE claim labels (>= 900) for CWT payloads
989
+ // ===========================================================================
990
+
991
+ describe("COSE interop: custom claim labels (>= 900)", () => {
992
+ test("CWT with custom claims produces integer labels in CBOR payload", () => {
993
+ const kryptos = createEcSigKey();
994
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
995
+
996
+ const { buffer } = kit.sign({
997
+ expires: "1h",
998
+ subject: SUBJECT,
999
+ tokenType: "access_token",
1000
+ claims: { 900: "user-abc", 901: 42 },
1001
+ });
1002
+
1003
+ const decoded = cborEncoder.decode(buffer);
1004
+ const [, , payloadCbor] = decoded;
1005
+
1006
+ const payloadMap: Map<number | string, unknown> = cborEncoder.decode(payloadCbor);
1007
+ expect(payloadMap).toBeInstanceOf(Map);
1008
+
1009
+ // Custom claims use integer keys
1010
+ expect(payloadMap.get(900)).toBe("user-abc");
1011
+ expect(payloadMap.get(901)).toBe(42);
1012
+
1013
+ // Standard claims still present
1014
+ expect(payloadMap.get(COSE_CLAIM.ISS)).toBe(ISSUER);
1015
+ });
1016
+
1017
+ test("CWT with custom claims verifiable by @auth0/cose Sign1", async () => {
1018
+ const kryptos = createEcSigKey();
1019
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
1020
+
1021
+ const { buffer } = kit.sign({
1022
+ expires: "1h",
1023
+ subject: SUBJECT,
1024
+ tokenType: "access_token",
1025
+ claims: { 900: "user-abc", 901: 42 },
1026
+ });
1027
+
1028
+ const sign1 = Sign1.decode(buffer);
1029
+ expect(sign1.protectedHeaders.get(Headers.Algorithm)).toBe(Algorithms.ES256);
1030
+
1031
+ const publicKey = await getJoseKey(kryptos, "public");
1032
+ await expect(sign1.verify(publicKey)).resolves.toBeUndefined();
1033
+ });
1034
+
1035
+ test("round-trip sign -> verify preserves custom claims", () => {
1036
+ const kryptos = createEcSigKey();
1037
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
1038
+
1039
+ const { token } = kit.sign({
1040
+ expires: "1h",
1041
+ subject: SUBJECT,
1042
+ tokenType: "access_token",
1043
+ claims: { 900: "user-abc", 901: 42 },
1044
+ });
1045
+
1046
+ const result = kit.verify(token);
1047
+ expect(result.payload.claims["900"]).toBe("user-abc");
1048
+ expect(result.payload.claims["901"]).toBe(42);
1049
+ expect(result.payload.issuer).toBe(ISSUER);
1050
+ });
1051
+
1052
+ test("external target: custom claims use string keys, no integer labels >= 900", () => {
1053
+ const kryptos = createEcSigKey();
1054
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
1055
+
1056
+ const { buffer } = kit.sign(
1057
+ {
1058
+ expires: "1h",
1059
+ subject: SUBJECT,
1060
+ tokenType: "access_token",
1061
+ claims: { 900: "user-abc" },
1062
+ },
1063
+ { target: "external" },
1064
+ );
1065
+
1066
+ const decoded = cborEncoder.decode(buffer);
1067
+ const [, , payloadCbor] = decoded;
1068
+
1069
+ const payloadMap: Map<number | string, unknown> = cborEncoder.decode(payloadCbor);
1070
+ expect(payloadMap).toBeInstanceOf(Map);
1071
+
1072
+ // No integer keys >= 900
1073
+ for (const key of payloadMap.keys()) {
1074
+ if (typeof key === "number") {
1075
+ expect(key).toBeLessThan(900);
1076
+ }
1077
+ }
1078
+
1079
+ // Custom claim present as string key
1080
+ expect(payloadMap.get("900")).toBe("user-abc");
1081
+ });
1082
+
1083
+ test("large custom labels (e.g. 10000) work correctly", () => {
1084
+ const kryptos = createEcSigKey();
1085
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
1086
+
1087
+ const { buffer, token } = kit.sign({
1088
+ expires: "1h",
1089
+ subject: SUBJECT,
1090
+ tokenType: "access_token",
1091
+ claims: { 10000: "large-label" },
1092
+ });
1093
+
1094
+ const decoded = cborEncoder.decode(buffer);
1095
+ const [, , payloadCbor] = decoded;
1096
+ const payloadMap: Map<number | string, unknown> = cborEncoder.decode(payloadCbor);
1097
+ expect(payloadMap.get(10000)).toBe("large-label");
1098
+
1099
+ const result = kit.verify(token);
1100
+ expect(result.payload.claims["10000"]).toBe("large-label");
1101
+ });
1102
+
1103
+ test("token with integer custom claims is smaller than string key equivalent", () => {
1104
+ const kryptos = createEcSigKey();
1105
+ const kit = new CwtKit({ issuer: ISSUER, logger, kryptos });
1106
+
1107
+ const internalResult = kit.sign({
1108
+ expires: "1h",
1109
+ subject: SUBJECT,
1110
+ tokenType: "access_token",
1111
+ claims: { 900: "user-abc", 901: 42 },
1112
+ });
1113
+
1114
+ const externalResult = kit.sign(
1115
+ {
1116
+ expires: "1h",
1117
+ subject: SUBJECT,
1118
+ tokenType: "access_token",
1119
+ claims: { 900: "user-abc", 901: 42 },
1120
+ },
1121
+ { target: "external" },
1122
+ );
1123
+
1124
+ // Internal (integer labels) should be smaller than external (string keys)
1125
+ expect(internalResult.buffer.length).toBeLessThan(externalResult.buffer.length);
1126
+ });
1127
+ });