@kokuin/token 0.4.0 → 0.5.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/lib/jwe.js DELETED
@@ -1,315 +0,0 @@
1
- import { gcm } from '@noble/ciphers/aes.js';
2
- import { randomBytes } from '@noble/ciphers/utils.js';
3
- import { ed25519, x25519 } from '@noble/curves/ed25519.js';
4
- import { sha256 } from '@noble/hashes/sha2.js';
5
- import { b64uFromJSON, b64uToJSON, fromB64U, toB64U } from '@sozai/codec';
6
- import { getAgreementKey, getSignatureInfo } from './did.js';
7
- import { decodePeer4, getPeer4ShortForm, isPeer4 } from './peer4.js';
8
- import { createUnsignedToken, isUnsignedToken, verifyToken } from './token.js';
9
- import { stringifyToken } from './utils.js';
10
- function uint32BE(value) {
11
- const buf = new Uint8Array(4);
12
- const view = new DataView(buf.buffer);
13
- view.setUint32(0, value, false);
14
- return buf;
15
- }
16
- function lengthPrefixed(data) {
17
- const prefix = uint32BE(data.length);
18
- const result = new Uint8Array(4 + data.length);
19
- result.set(prefix);
20
- result.set(data, 4);
21
- return result;
22
- }
23
- /**
24
- * Concat KDF per RFC 7518 Section 4.6.2.
25
- * Single SHA-256 iteration (sufficient for 256-bit keys).
26
- */ export function concatKDF(params) {
27
- const { sharedSecret, keyLength, algorithmID, partyUInfo, partyVInfo } = params;
28
- const encoder = new TextEncoder();
29
- const algID = lengthPrefixed(encoder.encode(algorithmID));
30
- const apu = lengthPrefixed(partyUInfo);
31
- const apv = lengthPrefixed(partyVInfo);
32
- const keyDataLen = uint32BE(keyLength);
33
- // round = 1 (single iteration for 256-bit key)
34
- const round = uint32BE(1);
35
- // Hash(round || sharedSecret || algID || apu || apv || keyDataLen)
36
- const parts = [
37
- round,
38
- sharedSecret,
39
- algID,
40
- apu,
41
- apv,
42
- keyDataLen
43
- ];
44
- let totalLength = 0;
45
- for (const part of parts){
46
- totalLength += part.length;
47
- }
48
- const hashInput = new Uint8Array(totalLength);
49
- let offset = 0;
50
- for (const part of parts){
51
- hashInput.set(part, offset);
52
- offset += part.length;
53
- }
54
- return sha256(hashInput).slice(0, keyLength / 8);
55
- }
56
- function agreeWithKey(recipientPublicKey) {
57
- const ephemeralPrivateKey = x25519.utils.randomSecretKey();
58
- return {
59
- ephemeralPublicKey: x25519.getPublicKey(ephemeralPrivateKey),
60
- sharedSecret: x25519.getSharedSecret(ephemeralPrivateKey, recipientPublicKey)
61
- };
62
- }
63
- function encryptWithX25519(recipientPublicKey, plaintext) {
64
- const { sharedSecret, ephemeralPublicKey } = agreeWithKey(recipientPublicKey);
65
- // Derive content encryption key via Concat KDF
66
- const cek = concatKDF({
67
- sharedSecret,
68
- keyLength: 256,
69
- algorithmID: 'A256GCM',
70
- partyUInfo: new Uint8Array(0),
71
- partyVInfo: new Uint8Array(0)
72
- });
73
- // Generate random 96-bit IV for AES-GCM
74
- const iv = randomBytes(12);
75
- // Build protected header
76
- const protectedHeader = {
77
- alg: 'ECDH-ES',
78
- enc: 'A256GCM',
79
- epk: {
80
- kty: 'OKP',
81
- crv: 'X25519',
82
- x: toB64U(ephemeralPublicKey)
83
- }
84
- };
85
- // Encode protected header
86
- const encodedHeader = b64uFromJSON(protectedHeader);
87
- // AAD is the ASCII bytes of the encoded header (per RFC 7516 Section 5.1 step 14)
88
- const aad = new TextEncoder().encode(encodedHeader);
89
- // Encrypt with AES-256-GCM (tag is appended to ciphertext)
90
- const cipher = gcm(cek, iv, aad);
91
- const ciphertextWithTag = cipher.encrypt(plaintext);
92
- // Split ciphertext and tag (GCM tag is last 16 bytes)
93
- const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - 16);
94
- const tag = ciphertextWithTag.slice(ciphertextWithTag.length - 16);
95
- // JWE Compact Serialization: header.encryptedKey.iv.ciphertext.tag
96
- // For ECDH-ES direct, encrypted key is empty
97
- return [
98
- encodedHeader,
99
- '',
100
- toB64U(iv),
101
- toB64U(ciphertext),
102
- toB64U(tag)
103
- ].join('.');
104
- }
105
- function resolveX25519Key(recipient) {
106
- if (typeof recipient !== 'string') {
107
- return {
108
- key: recipient
109
- };
110
- }
111
- if (isPeer4(recipient)) {
112
- const shortForm = getPeer4ShortForm(recipient);
113
- if (recipient === shortForm) {
114
- // The doc lives in the long form. Resolving a short form needs a DIDResolver, which
115
- // this sync constructor cannot await — so say so, rather than failing as a bad DID.
116
- throw new Error(`Cannot encrypt to a did:peer:4 short form: ${shortForm}. Pass the long form, which carries the document.`);
117
- }
118
- const { doc } = decodePeer4(recipient);
119
- const key = getAgreementKey(doc);
120
- if (key == null) {
121
- throw new Error(`Recipient publishes no X25519 keyAgreement key: ${shortForm}`);
122
- }
123
- return {
124
- key,
125
- id: shortForm
126
- };
127
- }
128
- const [algorithm, publicKey] = getSignatureInfo(recipient);
129
- if (algorithm === 'EdDSA') {
130
- return {
131
- key: ed25519.utils.toMontgomery(publicKey),
132
- id: recipient
133
- };
134
- }
135
- throw new Error(`Unsupported DID algorithm for encryption: ${algorithm}`);
136
- }
137
- /**
138
- * Perform X25519 key agreement with a recipient DID, without building a JWE.
139
- *
140
- * Resolves the recipient's agreement key by the same rule `createTokenEncrypter` uses — a
141
- * did:peer:4 identity's published `keyAgreement` key, or an EdDSA `did:key`'s birationally
142
- * derived one — generates a single-use ephemeral key pair, and returns the ECDH output.
143
- * The ephemeral private key never leaves this function.
144
- *
145
- * The recipient recovers the identical bytes with `identity.agreeKey(ephemeralPublicKey)` —
146
- * called with no `kid`; the sender always resolves the first published `keyAgreement` key, and
147
- * on a multi-KEM identity a different `kid` silently recovers different bytes.
148
- *
149
- * The agreement is anonymous — it authenticates nothing about the sender, only that the secret
150
- * is recoverable by the recipient. Bind `ephemeralPublicKey` and the recipient DID into your
151
- * KDF's info/context.
152
- *
153
- * ```ts
154
- * const { sharedSecret, ephemeralPublicKey } = deriveSharedSecret(recipientDID)
155
- * // ship ephemeralPublicKey alongside whatever the secret protects
156
- * ```
157
- *
158
- * The result is a raw ECDH output, not a key: run it through a KDF before use.
159
- *
160
- * @param recipient a `did:key` EdDSA DID, or a `did:peer:4` **long form**. A peer:4 short form
161
- * throws — the document that carries the agreement key lives only in the long form.
162
- */ export function deriveSharedSecret(recipient) {
163
- if (typeof recipient !== 'string') {
164
- throw new Error('deriveSharedSecret requires a DID string');
165
- }
166
- return agreeWithKey(resolveX25519Key(recipient).key);
167
- }
168
- export function createTokenEncrypter(recipient, options) {
169
- if (typeof recipient !== 'string' && options?.algorithm !== 'X25519') {
170
- throw new Error(`Unsupported algorithm: ${options?.algorithm}`);
171
- }
172
- const { key, id } = resolveX25519Key(recipient);
173
- return {
174
- recipientID: id,
175
- async encrypt (plaintext) {
176
- return encryptWithX25519(key, plaintext);
177
- }
178
- };
179
- }
180
- /**
181
- * Encrypt plaintext to JWE compact serialization using the given encrypter.
182
- */ export async function encryptToken(encrypter, plaintext) {
183
- return encrypter.encrypt(plaintext);
184
- }
185
- /**
186
- * Decrypt a JWE compact serialization string.
187
- */ export async function decryptToken(decrypter, jwe) {
188
- const parts = jwe.split('.');
189
- if (parts.length !== 5) {
190
- throw new Error(`Invalid JWE format: expected 5 parts, got ${parts.length}`);
191
- }
192
- const [encodedHeader, _encryptedKey, encodedIV, encodedCiphertext, encodedTag] = parts;
193
- // Parse protected header
194
- const header = b64uToJSON(encodedHeader);
195
- if (header.alg !== 'ECDH-ES') {
196
- throw new Error(`Unsupported JWE algorithm: ${header.alg}`);
197
- }
198
- if (header.enc !== 'A256GCM') {
199
- throw new Error(`Unsupported JWE encryption: ${header.enc}`);
200
- }
201
- // Extract ephemeral public key from header
202
- const ephemeralPublicKey = fromB64U(header.epk.x);
203
- // Compute shared secret via ECDH key agreement
204
- const sharedSecret = await decrypter.agreeKey(ephemeralPublicKey);
205
- // Derive content encryption key
206
- const cek = concatKDF({
207
- sharedSecret,
208
- keyLength: 256,
209
- algorithmID: 'A256GCM',
210
- partyUInfo: header.apu != null ? fromB64U(header.apu) : new Uint8Array(0),
211
- partyVInfo: header.apv != null ? fromB64U(header.apv) : new Uint8Array(0)
212
- });
213
- // Decode components
214
- const iv = fromB64U(encodedIV);
215
- const ciphertext = fromB64U(encodedCiphertext);
216
- const tag = fromB64U(encodedTag);
217
- // Reconstruct ciphertext+tag (GCM expects them concatenated)
218
- const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
219
- ciphertextWithTag.set(ciphertext);
220
- ciphertextWithTag.set(tag, ciphertext.length);
221
- // AAD is the ASCII bytes of the encoded header
222
- const aad = new TextEncoder().encode(encodedHeader);
223
- // Decrypt with AES-256-GCM
224
- const cipher = gcm(cek, iv, aad);
225
- return cipher.decrypt(ciphertextWithTag);
226
- }
227
- /**
228
- * Wrap a payload into a token string according to the specified envelope mode.
229
- */ export async function wrapEnvelope(mode, payload, options) {
230
- switch(mode){
231
- case 'plain':
232
- {
233
- const token = createUnsignedToken(payload, options.header);
234
- // Append empty signature segment for RFC 7519 unsecured JWT format (header.payload.)
235
- return `${stringifyToken(token)}.`;
236
- }
237
- case 'jws':
238
- {
239
- if (options.signer == null) throw new Error('Signer required for jws mode');
240
- const token = await options.signer.signToken(payload, {
241
- header: options.header
242
- });
243
- return stringifyToken(token);
244
- }
245
- case 'jws-in-jwe':
246
- {
247
- if (options.signer == null) throw new Error('Signer required for jws-in-jwe mode');
248
- if (options.encrypter == null) throw new Error('Encrypter required for jws-in-jwe mode');
249
- const signed = await options.signer.signToken(payload, {
250
- header: options.header
251
- });
252
- const jwsString = stringifyToken(signed);
253
- return encryptToken(options.encrypter, new TextEncoder().encode(jwsString));
254
- }
255
- case 'jwe-in-jws':
256
- {
257
- if (options.signer == null) throw new Error('Signer required for jwe-in-jws mode');
258
- if (options.encrypter == null) throw new Error('Encrypter required for jwe-in-jws mode');
259
- const plaintext = new TextEncoder().encode(JSON.stringify(payload));
260
- const jwe = await encryptToken(options.encrypter, plaintext);
261
- const signed = await options.signer.signToken({
262
- jwe
263
- }, {
264
- header: options.header
265
- });
266
- return stringifyToken(signed);
267
- }
268
- }
269
- }
270
- /**
271
- * Unwrap a token string, auto-detecting the envelope mode from its structure.
272
- */ export async function unwrapEnvelope(message, options) {
273
- const parts = message.split('.');
274
- if (parts.length === 5) {
275
- // JWE outer → jws-in-jwe mode
276
- if (options.decrypter == null) throw new Error('Decrypter required for JWE message');
277
- const decrypted = await decryptToken(options.decrypter, message);
278
- const jwsString = new TextDecoder().decode(decrypted);
279
- const token = await verifyToken(jwsString, {
280
- verifiers: options.verifiers
281
- });
282
- return {
283
- payload: token.payload,
284
- mode: 'jws-in-jwe'
285
- };
286
- }
287
- if (parts.length === 3) {
288
- // JWT: could be plain, jws, or jwe-in-jws
289
- const token = await verifyToken(message, {
290
- verifiers: options.verifiers,
291
- allowUnsigned: true
292
- });
293
- if (isUnsignedToken(token)) {
294
- return {
295
- payload: token.payload,
296
- mode: 'plain'
297
- };
298
- }
299
- // Check if payload contains a JWE (jwe-in-jws)
300
- if ('jwe' in token.payload && typeof token.payload.jwe === 'string') {
301
- if (options.decrypter == null) throw new Error('Decrypter required for jwe-in-jws message');
302
- const decrypted = await decryptToken(options.decrypter, token.payload.jwe);
303
- const innerPayload = JSON.parse(new TextDecoder().decode(decrypted));
304
- return {
305
- payload: innerPayload,
306
- mode: 'jwe-in-jws'
307
- };
308
- }
309
- return {
310
- payload: token.payload,
311
- mode: 'jws'
312
- };
313
- }
314
- throw new Error(`Invalid envelope format: expected 3 or 5 dot-separated parts, got ${parts.length}`);
315
- }
package/lib/rotation.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { MultiKeyIdentity } from './identity.js';
2
- import type { DIDString, SignedToken } from './types.js';
3
- export type RotationPayload = {
4
- type: 'did-rotation';
5
- iss?: DIDString;
6
- to: DIDString;
7
- toLongForm: DIDString;
8
- issuedAt: number;
9
- };
10
- /**
11
- * Sign a rotation assertion linking an old identity to a new one.
12
- * The assertion is a regular signed token issued by the old identity: its `iss` is that identity's
13
- * DID, in long form for a `did:peer:4` signer since the payload names no audience.
14
- * Verifiers walking a rotation chain can use this to link related identities.
15
- */
16
- export declare function createRotationAssertion(oldIdentity: MultiKeyIdentity, newIdentity: MultiKeyIdentity, issuedAt?: number): Promise<SignedToken<RotationPayload>>;
package/lib/rotation.js DELETED
@@ -1,13 +0,0 @@
1
- /**
2
- * Sign a rotation assertion linking an old identity to a new one.
3
- * The assertion is a regular signed token issued by the old identity: its `iss` is that identity's
4
- * DID, in long form for a `did:peer:4` signer since the payload names no audience.
5
- * Verifiers walking a rotation chain can use this to link related identities.
6
- */ export async function createRotationAssertion(oldIdentity, newIdentity, issuedAt = Math.floor(Date.now() / 1000)) {
7
- return oldIdentity.signToken({
8
- type: 'did-rotation',
9
- to: newIdentity.id,
10
- toLongForm: newIdentity.longForm,
11
- issuedAt
12
- });
13
- }