@kokuin/token 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/README.md +7 -0
- package/lib/cache.d.ts +23 -0
- package/lib/cache.js +41 -0
- package/lib/did.d.ts +39 -0
- package/lib/did.js +146 -0
- package/lib/identity.d.ts +119 -0
- package/lib/identity.js +344 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.js +23 -0
- package/lib/jwe.d.ts +67 -0
- package/lib/jwe.js +262 -0
- package/lib/keystore.d.ts +10 -0
- package/lib/keystore.js +1 -0
- package/lib/multibase.d.ts +4 -0
- package/lib/multibase.js +36 -0
- package/lib/peer4.d.ts +112 -0
- package/lib/peer4.js +158 -0
- package/lib/rotation.d.ts +15 -0
- package/lib/rotation.js +12 -0
- package/lib/schemas.d.ts +161 -0
- package/lib/schemas.js +96 -0
- package/lib/signer.d.ts +6 -0
- package/lib/signer.js +6 -0
- package/lib/time.d.ts +29 -0
- package/lib/time.js +18 -0
- package/lib/token.d.ts +53 -0
- package/lib/token.js +181 -0
- package/lib/types.d.ts +17 -0
- package/lib/types.js +1 -0
- package/lib/utils.d.ts +6 -0
- package/lib/utils.js +19 -0
- package/lib/verifier.d.ts +9 -0
- package/lib/verifier.js +19 -0
- package/package.json +49 -0
package/lib/jwe.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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 { getSignatureInfo } from './did.js';
|
|
7
|
+
import { createUnsignedToken, isUnsignedToken, verifyToken } from './token.js';
|
|
8
|
+
import { stringifyToken } from './utils.js';
|
|
9
|
+
function uint32BE(value) {
|
|
10
|
+
const buf = new Uint8Array(4);
|
|
11
|
+
const view = new DataView(buf.buffer);
|
|
12
|
+
view.setUint32(0, value, false);
|
|
13
|
+
return buf;
|
|
14
|
+
}
|
|
15
|
+
function lengthPrefixed(data) {
|
|
16
|
+
const prefix = uint32BE(data.length);
|
|
17
|
+
const result = new Uint8Array(4 + data.length);
|
|
18
|
+
result.set(prefix);
|
|
19
|
+
result.set(data, 4);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Concat KDF per RFC 7518 Section 4.6.2.
|
|
24
|
+
* Single SHA-256 iteration (sufficient for 256-bit keys).
|
|
25
|
+
*/ export function concatKDF(params) {
|
|
26
|
+
const { sharedSecret, keyLength, algorithmID, partyUInfo, partyVInfo } = params;
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const algID = lengthPrefixed(encoder.encode(algorithmID));
|
|
29
|
+
const apu = lengthPrefixed(partyUInfo);
|
|
30
|
+
const apv = lengthPrefixed(partyVInfo);
|
|
31
|
+
const keyDataLen = uint32BE(keyLength);
|
|
32
|
+
// round = 1 (single iteration for 256-bit key)
|
|
33
|
+
const round = uint32BE(1);
|
|
34
|
+
// Hash(round || sharedSecret || algID || apu || apv || keyDataLen)
|
|
35
|
+
const parts = [
|
|
36
|
+
round,
|
|
37
|
+
sharedSecret,
|
|
38
|
+
algID,
|
|
39
|
+
apu,
|
|
40
|
+
apv,
|
|
41
|
+
keyDataLen
|
|
42
|
+
];
|
|
43
|
+
let totalLength = 0;
|
|
44
|
+
for (const part of parts){
|
|
45
|
+
totalLength += part.length;
|
|
46
|
+
}
|
|
47
|
+
const hashInput = new Uint8Array(totalLength);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
for (const part of parts){
|
|
50
|
+
hashInput.set(part, offset);
|
|
51
|
+
offset += part.length;
|
|
52
|
+
}
|
|
53
|
+
return sha256(hashInput).slice(0, keyLength / 8);
|
|
54
|
+
}
|
|
55
|
+
function encryptWithX25519(recipientPublicKey, plaintext) {
|
|
56
|
+
// Generate ephemeral X25519 key pair
|
|
57
|
+
const ephemeralPrivateKey = x25519.utils.randomSecretKey();
|
|
58
|
+
const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey);
|
|
59
|
+
// Compute shared secret via ECDH
|
|
60
|
+
const sharedSecret = x25519.getSharedSecret(ephemeralPrivateKey, recipientPublicKey);
|
|
61
|
+
// Derive content encryption key via Concat KDF
|
|
62
|
+
const cek = concatKDF({
|
|
63
|
+
sharedSecret,
|
|
64
|
+
keyLength: 256,
|
|
65
|
+
algorithmID: 'A256GCM',
|
|
66
|
+
partyUInfo: new Uint8Array(0),
|
|
67
|
+
partyVInfo: new Uint8Array(0)
|
|
68
|
+
});
|
|
69
|
+
// Generate random 96-bit IV for AES-GCM
|
|
70
|
+
const iv = randomBytes(12);
|
|
71
|
+
// Build protected header
|
|
72
|
+
const protectedHeader = {
|
|
73
|
+
alg: 'ECDH-ES',
|
|
74
|
+
enc: 'A256GCM',
|
|
75
|
+
epk: {
|
|
76
|
+
kty: 'OKP',
|
|
77
|
+
crv: 'X25519',
|
|
78
|
+
x: toB64U(ephemeralPublicKey)
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// Encode protected header
|
|
82
|
+
const encodedHeader = b64uFromJSON(protectedHeader);
|
|
83
|
+
// AAD is the ASCII bytes of the encoded header (per RFC 7516 Section 5.1 step 14)
|
|
84
|
+
const aad = new TextEncoder().encode(encodedHeader);
|
|
85
|
+
// Encrypt with AES-256-GCM (tag is appended to ciphertext)
|
|
86
|
+
const cipher = gcm(cek, iv, aad);
|
|
87
|
+
const ciphertextWithTag = cipher.encrypt(plaintext);
|
|
88
|
+
// Split ciphertext and tag (GCM tag is last 16 bytes)
|
|
89
|
+
const ciphertext = ciphertextWithTag.slice(0, ciphertextWithTag.length - 16);
|
|
90
|
+
const tag = ciphertextWithTag.slice(ciphertextWithTag.length - 16);
|
|
91
|
+
// JWE Compact Serialization: header.encryptedKey.iv.ciphertext.tag
|
|
92
|
+
// For ECDH-ES direct, encrypted key is empty
|
|
93
|
+
return [
|
|
94
|
+
encodedHeader,
|
|
95
|
+
'',
|
|
96
|
+
toB64U(iv),
|
|
97
|
+
toB64U(ciphertext),
|
|
98
|
+
toB64U(tag)
|
|
99
|
+
].join('.');
|
|
100
|
+
}
|
|
101
|
+
function resolveX25519Key(recipient) {
|
|
102
|
+
if (typeof recipient !== 'string') {
|
|
103
|
+
return {
|
|
104
|
+
key: recipient
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const [algorithm, publicKey] = getSignatureInfo(recipient);
|
|
108
|
+
if (algorithm === 'EdDSA') {
|
|
109
|
+
return {
|
|
110
|
+
key: ed25519.utils.toMontgomery(publicKey),
|
|
111
|
+
id: recipient
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Unsupported DID algorithm for encryption: ${algorithm}`);
|
|
115
|
+
}
|
|
116
|
+
export function createTokenEncrypter(recipient, options) {
|
|
117
|
+
if (typeof recipient !== 'string' && options?.algorithm !== 'X25519') {
|
|
118
|
+
throw new Error(`Unsupported algorithm: ${options?.algorithm}`);
|
|
119
|
+
}
|
|
120
|
+
const { key, id } = resolveX25519Key(recipient);
|
|
121
|
+
return {
|
|
122
|
+
recipientID: id,
|
|
123
|
+
async encrypt (plaintext) {
|
|
124
|
+
return encryptWithX25519(key, plaintext);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Encrypt plaintext to JWE compact serialization using the given encrypter.
|
|
130
|
+
*/ export async function encryptToken(encrypter, plaintext) {
|
|
131
|
+
return encrypter.encrypt(plaintext);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Decrypt a JWE compact serialization string.
|
|
135
|
+
*/ export async function decryptToken(decrypter, jwe) {
|
|
136
|
+
const parts = jwe.split('.');
|
|
137
|
+
if (parts.length !== 5) {
|
|
138
|
+
throw new Error(`Invalid JWE format: expected 5 parts, got ${parts.length}`);
|
|
139
|
+
}
|
|
140
|
+
const [encodedHeader, _encryptedKey, encodedIV, encodedCiphertext, encodedTag] = parts;
|
|
141
|
+
// Parse protected header
|
|
142
|
+
const header = b64uToJSON(encodedHeader);
|
|
143
|
+
if (header.alg !== 'ECDH-ES') {
|
|
144
|
+
throw new Error(`Unsupported JWE algorithm: ${header.alg}`);
|
|
145
|
+
}
|
|
146
|
+
if (header.enc !== 'A256GCM') {
|
|
147
|
+
throw new Error(`Unsupported JWE encryption: ${header.enc}`);
|
|
148
|
+
}
|
|
149
|
+
// Extract ephemeral public key from header
|
|
150
|
+
const ephemeralPublicKey = fromB64U(header.epk.x);
|
|
151
|
+
// Compute shared secret via ECDH key agreement
|
|
152
|
+
const sharedSecret = await decrypter.agreeKey(ephemeralPublicKey);
|
|
153
|
+
// Derive content encryption key
|
|
154
|
+
const cek = concatKDF({
|
|
155
|
+
sharedSecret,
|
|
156
|
+
keyLength: 256,
|
|
157
|
+
algorithmID: 'A256GCM',
|
|
158
|
+
partyUInfo: header.apu != null ? fromB64U(header.apu) : new Uint8Array(0),
|
|
159
|
+
partyVInfo: header.apv != null ? fromB64U(header.apv) : new Uint8Array(0)
|
|
160
|
+
});
|
|
161
|
+
// Decode components
|
|
162
|
+
const iv = fromB64U(encodedIV);
|
|
163
|
+
const ciphertext = fromB64U(encodedCiphertext);
|
|
164
|
+
const tag = fromB64U(encodedTag);
|
|
165
|
+
// Reconstruct ciphertext+tag (GCM expects them concatenated)
|
|
166
|
+
const ciphertextWithTag = new Uint8Array(ciphertext.length + tag.length);
|
|
167
|
+
ciphertextWithTag.set(ciphertext);
|
|
168
|
+
ciphertextWithTag.set(tag, ciphertext.length);
|
|
169
|
+
// AAD is the ASCII bytes of the encoded header
|
|
170
|
+
const aad = new TextEncoder().encode(encodedHeader);
|
|
171
|
+
// Decrypt with AES-256-GCM
|
|
172
|
+
const cipher = gcm(cek, iv, aad);
|
|
173
|
+
return cipher.decrypt(ciphertextWithTag);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Wrap a payload into a token string according to the specified envelope mode.
|
|
177
|
+
*/ export async function wrapEnvelope(mode, payload, options) {
|
|
178
|
+
switch(mode){
|
|
179
|
+
case 'plain':
|
|
180
|
+
{
|
|
181
|
+
const token = createUnsignedToken(payload, options.header);
|
|
182
|
+
// Append empty signature segment for RFC 7519 unsecured JWT format (header.payload.)
|
|
183
|
+
return `${stringifyToken(token)}.`;
|
|
184
|
+
}
|
|
185
|
+
case 'jws':
|
|
186
|
+
{
|
|
187
|
+
if (options.signer == null) throw new Error('Signer required for jws mode');
|
|
188
|
+
const token = await options.signer.signToken(payload, {
|
|
189
|
+
header: options.header
|
|
190
|
+
});
|
|
191
|
+
return stringifyToken(token);
|
|
192
|
+
}
|
|
193
|
+
case 'jws-in-jwe':
|
|
194
|
+
{
|
|
195
|
+
if (options.signer == null) throw new Error('Signer required for jws-in-jwe mode');
|
|
196
|
+
if (options.encrypter == null) throw new Error('Encrypter required for jws-in-jwe mode');
|
|
197
|
+
const signed = await options.signer.signToken(payload, {
|
|
198
|
+
header: options.header
|
|
199
|
+
});
|
|
200
|
+
const jwsString = stringifyToken(signed);
|
|
201
|
+
return encryptToken(options.encrypter, new TextEncoder().encode(jwsString));
|
|
202
|
+
}
|
|
203
|
+
case 'jwe-in-jws':
|
|
204
|
+
{
|
|
205
|
+
if (options.signer == null) throw new Error('Signer required for jwe-in-jws mode');
|
|
206
|
+
if (options.encrypter == null) throw new Error('Encrypter required for jwe-in-jws mode');
|
|
207
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
208
|
+
const jwe = await encryptToken(options.encrypter, plaintext);
|
|
209
|
+
const signed = await options.signer.signToken({
|
|
210
|
+
jwe
|
|
211
|
+
}, {
|
|
212
|
+
header: options.header
|
|
213
|
+
});
|
|
214
|
+
return stringifyToken(signed);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Unwrap a token string, auto-detecting the envelope mode from its structure.
|
|
220
|
+
*/ export async function unwrapEnvelope(message, options) {
|
|
221
|
+
const parts = message.split('.');
|
|
222
|
+
if (parts.length === 5) {
|
|
223
|
+
// JWE outer → jws-in-jwe mode
|
|
224
|
+
if (options.decrypter == null) throw new Error('Decrypter required for JWE message');
|
|
225
|
+
const decrypted = await decryptToken(options.decrypter, message);
|
|
226
|
+
const jwsString = new TextDecoder().decode(decrypted);
|
|
227
|
+
const token = await verifyToken(jwsString, {
|
|
228
|
+
verifiers: options.verifiers
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
payload: token.payload,
|
|
232
|
+
mode: 'jws-in-jwe'
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (parts.length === 3) {
|
|
236
|
+
// JWT: could be plain, jws, or jwe-in-jws
|
|
237
|
+
const token = await verifyToken(message, {
|
|
238
|
+
verifiers: options.verifiers
|
|
239
|
+
});
|
|
240
|
+
if (isUnsignedToken(token)) {
|
|
241
|
+
return {
|
|
242
|
+
payload: token.payload,
|
|
243
|
+
mode: 'plain'
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// Check if payload contains a JWE (jwe-in-jws)
|
|
247
|
+
if ('jwe' in token.payload && typeof token.payload.jwe === 'string') {
|
|
248
|
+
if (options.decrypter == null) throw new Error('Decrypter required for jwe-in-jws message');
|
|
249
|
+
const decrypted = await decryptToken(options.decrypter, token.payload.jwe);
|
|
250
|
+
const innerPayload = JSON.parse(new TextDecoder().decode(decrypted));
|
|
251
|
+
return {
|
|
252
|
+
payload: innerPayload,
|
|
253
|
+
mode: 'jwe-in-jws'
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
payload: token.payload,
|
|
258
|
+
mode: 'jws'
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`Invalid envelope format: expected 3 or 5 dot-separated parts, got ${parts.length}`);
|
|
262
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type KeyEntry<PrivateKeyType> = {
|
|
2
|
+
readonly keyID: string;
|
|
3
|
+
getAsync(): Promise<PrivateKeyType | null>;
|
|
4
|
+
setAsync(privateKey: PrivateKeyType): Promise<void>;
|
|
5
|
+
provideAsync(): Promise<PrivateKeyType>;
|
|
6
|
+
removeAsync(): Promise<void>;
|
|
7
|
+
};
|
|
8
|
+
export type KeyStore<PrivateKeyType, EntryType extends KeyEntry<PrivateKeyType> = KeyEntry<PrivateKeyType>> = {
|
|
9
|
+
entry(keyID: string): EntryType;
|
|
10
|
+
};
|
package/lib/keystore.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function encodeMultibase(bytes: Uint8Array): string;
|
|
2
|
+
export declare function decodeMultibase(value: string): Uint8Array;
|
|
3
|
+
export declare function multihashSHA256(bytes: Uint8Array): Uint8Array;
|
|
4
|
+
export declare function verifyMultihash(multihash: Uint8Array, bytes: Uint8Array): boolean;
|
package/lib/multibase.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
2
|
+
import { base58 } from '@scure/base';
|
|
3
|
+
const MULTIBASE_BASE58BTC = 'z';
|
|
4
|
+
const MULTIHASH_SHA256_CODE = 0x12;
|
|
5
|
+
const MULTIHASH_SHA256_LENGTH = 0x20;
|
|
6
|
+
export function encodeMultibase(bytes) {
|
|
7
|
+
return MULTIBASE_BASE58BTC + base58.encode(bytes);
|
|
8
|
+
}
|
|
9
|
+
export function decodeMultibase(value) {
|
|
10
|
+
if (value.length === 0) {
|
|
11
|
+
throw new Error('Invalid multibase encoding: empty string');
|
|
12
|
+
}
|
|
13
|
+
const prefix = value[0];
|
|
14
|
+
if (prefix !== MULTIBASE_BASE58BTC) {
|
|
15
|
+
throw new Error(`Unsupported multibase prefix: ${prefix}`);
|
|
16
|
+
}
|
|
17
|
+
return base58.decode(value.slice(1));
|
|
18
|
+
}
|
|
19
|
+
export function multihashSHA256(bytes) {
|
|
20
|
+
const digest = sha256(bytes);
|
|
21
|
+
const out = new Uint8Array(2 + digest.length);
|
|
22
|
+
out[0] = MULTIHASH_SHA256_CODE;
|
|
23
|
+
out[1] = MULTIHASH_SHA256_LENGTH;
|
|
24
|
+
out.set(digest, 2);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export function verifyMultihash(multihash, bytes) {
|
|
28
|
+
if (multihash.length !== 2 + MULTIHASH_SHA256_LENGTH) return false;
|
|
29
|
+
if (multihash[0] !== MULTIHASH_SHA256_CODE) return false;
|
|
30
|
+
if (multihash[1] !== MULTIHASH_SHA256_LENGTH) return false;
|
|
31
|
+
const expected = sha256(bytes);
|
|
32
|
+
for(let i = 0; i < MULTIHASH_SHA256_LENGTH; i++){
|
|
33
|
+
if (multihash[2 + i] !== expected[i]) return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
package/lib/peer4.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type FromSchema } from '@sozai/schema';
|
|
2
|
+
/** @internal */
|
|
3
|
+
export declare const verificationMethodSchema: {
|
|
4
|
+
readonly type: "object";
|
|
5
|
+
readonly properties: {
|
|
6
|
+
readonly id: {
|
|
7
|
+
readonly type: "string";
|
|
8
|
+
};
|
|
9
|
+
readonly type: {
|
|
10
|
+
readonly type: "string";
|
|
11
|
+
};
|
|
12
|
+
readonly publicKeyMultibase: {
|
|
13
|
+
readonly type: "string";
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
readonly required: readonly ["id", "type", "publicKeyMultibase"];
|
|
17
|
+
readonly additionalProperties: true;
|
|
18
|
+
};
|
|
19
|
+
/** @internal */
|
|
20
|
+
export type VerificationMethod = FromSchema<typeof verificationMethodSchema>;
|
|
21
|
+
/** @internal */
|
|
22
|
+
export declare const didDocSchema: {
|
|
23
|
+
readonly type: "object";
|
|
24
|
+
readonly properties: {
|
|
25
|
+
readonly '@context': {
|
|
26
|
+
readonly anyOf: readonly [{
|
|
27
|
+
readonly type: "string";
|
|
28
|
+
}, {
|
|
29
|
+
readonly type: "array";
|
|
30
|
+
readonly items: {
|
|
31
|
+
readonly type: "string";
|
|
32
|
+
};
|
|
33
|
+
}];
|
|
34
|
+
};
|
|
35
|
+
readonly verificationMethod: {
|
|
36
|
+
readonly type: "array";
|
|
37
|
+
readonly items: {
|
|
38
|
+
readonly type: "object";
|
|
39
|
+
readonly properties: {
|
|
40
|
+
readonly id: {
|
|
41
|
+
readonly type: "string";
|
|
42
|
+
};
|
|
43
|
+
readonly type: {
|
|
44
|
+
readonly type: "string";
|
|
45
|
+
};
|
|
46
|
+
readonly publicKeyMultibase: {
|
|
47
|
+
readonly type: "string";
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
readonly required: readonly ["id", "type", "publicKeyMultibase"];
|
|
51
|
+
readonly additionalProperties: true;
|
|
52
|
+
};
|
|
53
|
+
readonly minItems: 1;
|
|
54
|
+
};
|
|
55
|
+
readonly authentication: {
|
|
56
|
+
readonly type: "array";
|
|
57
|
+
readonly items: {
|
|
58
|
+
readonly type: "string";
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
readonly keyAgreement: {
|
|
62
|
+
readonly type: "array";
|
|
63
|
+
readonly items: {
|
|
64
|
+
readonly type: "string";
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
readonly assertionMethod: {
|
|
68
|
+
readonly type: "array";
|
|
69
|
+
readonly items: {
|
|
70
|
+
readonly type: "string";
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
readonly required: readonly ["verificationMethod"];
|
|
75
|
+
readonly additionalProperties: true;
|
|
76
|
+
};
|
|
77
|
+
/** @internal */
|
|
78
|
+
export type DIDDoc = FromSchema<typeof didDocSchema>;
|
|
79
|
+
/**
|
|
80
|
+
* Validate that an unknown value conforms to the DID document schema used inside did:peer:4.
|
|
81
|
+
*/
|
|
82
|
+
export declare function validateDIDDoc(value: unknown): value is DIDDoc;
|
|
83
|
+
export type DecodePeer4Options = {
|
|
84
|
+
/** Maximum allowed size of the canonical doc bytes. Default 64 KB. */
|
|
85
|
+
maxDocSize?: number;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Check whether a value is a did:peer:4 identifier (either form).
|
|
89
|
+
*/
|
|
90
|
+
export declare function isPeer4(value: unknown): value is string;
|
|
91
|
+
/**
|
|
92
|
+
* Extract the short form from either a short or long did:peer:4 string.
|
|
93
|
+
*/
|
|
94
|
+
export declare function getPeer4ShortForm(did: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Decode a did:peer:4 long form into its DID document.
|
|
97
|
+
* Verifies the embedded hash against the encoded doc; throws on mismatch.
|
|
98
|
+
*/
|
|
99
|
+
export declare function decodePeer4(longForm: string, options?: DecodePeer4Options): {
|
|
100
|
+
longForm: string;
|
|
101
|
+
shortForm: string;
|
|
102
|
+
doc: DIDDoc;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Encode a DID document as a did:peer:4 identifier.
|
|
106
|
+
* Returns both the long form (self-contained, doc embedded) and short form (hash only).
|
|
107
|
+
*/
|
|
108
|
+
export declare function encodePeer4(doc: DIDDoc): {
|
|
109
|
+
longForm: string;
|
|
110
|
+
shortForm: string;
|
|
111
|
+
doc: DIDDoc;
|
|
112
|
+
};
|
package/lib/peer4.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { canonicalStringify, fromUTF, toUTF } from '@sozai/codec';
|
|
2
|
+
import { createValidator, isType } from '@sozai/schema';
|
|
3
|
+
import { decodeMultibase, encodeMultibase, multihashSHA256, verifyMultihash } from './multibase.js';
|
|
4
|
+
import { concatBytes } from './utils.js';
|
|
5
|
+
/** @internal */ export const verificationMethodSchema = {
|
|
6
|
+
type: 'object',
|
|
7
|
+
properties: {
|
|
8
|
+
id: {
|
|
9
|
+
type: 'string'
|
|
10
|
+
},
|
|
11
|
+
type: {
|
|
12
|
+
type: 'string'
|
|
13
|
+
},
|
|
14
|
+
publicKeyMultibase: {
|
|
15
|
+
type: 'string'
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
required: [
|
|
19
|
+
'id',
|
|
20
|
+
'type',
|
|
21
|
+
'publicKeyMultibase'
|
|
22
|
+
],
|
|
23
|
+
additionalProperties: true
|
|
24
|
+
};
|
|
25
|
+
/** @internal */ export const didDocSchema = {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
'@context': {
|
|
29
|
+
anyOf: [
|
|
30
|
+
{
|
|
31
|
+
type: 'string'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: 'array',
|
|
35
|
+
items: {
|
|
36
|
+
type: 'string'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
verificationMethod: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: verificationMethodSchema,
|
|
44
|
+
minItems: 1
|
|
45
|
+
},
|
|
46
|
+
authentication: {
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: {
|
|
49
|
+
type: 'string'
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
keyAgreement: {
|
|
53
|
+
type: 'array',
|
|
54
|
+
items: {
|
|
55
|
+
type: 'string'
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
assertionMethod: {
|
|
59
|
+
type: 'array',
|
|
60
|
+
items: {
|
|
61
|
+
type: 'string'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
required: [
|
|
66
|
+
'verificationMethod'
|
|
67
|
+
],
|
|
68
|
+
additionalProperties: true
|
|
69
|
+
};
|
|
70
|
+
const didDocValidator = createValidator(didDocSchema);
|
|
71
|
+
/**
|
|
72
|
+
* Validate that an unknown value conforms to the DID document schema used inside did:peer:4.
|
|
73
|
+
*/ export function validateDIDDoc(value) {
|
|
74
|
+
return isType(didDocValidator, value);
|
|
75
|
+
}
|
|
76
|
+
const PEER4_PREFIX = 'did:peer:4';
|
|
77
|
+
const JSON_MULTICODEC = new Uint8Array([
|
|
78
|
+
0x80,
|
|
79
|
+
0x04
|
|
80
|
+
]);
|
|
81
|
+
const DEFAULT_MAX_DOC_SIZE = 64 * 1024;
|
|
82
|
+
/**
|
|
83
|
+
* Check whether a value is a did:peer:4 identifier (either form).
|
|
84
|
+
*/ export function isPeer4(value) {
|
|
85
|
+
return typeof value === 'string' && value.startsWith(`${PEER4_PREFIX}z`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Extract the short form from either a short or long did:peer:4 string.
|
|
89
|
+
*/ export function getPeer4ShortForm(did) {
|
|
90
|
+
if (!isPeer4(did)) {
|
|
91
|
+
throw new Error('Invalid did:peer:4 identifier');
|
|
92
|
+
}
|
|
93
|
+
const sep = did.indexOf(':', PEER4_PREFIX.length);
|
|
94
|
+
return sep === -1 ? did : did.slice(0, sep);
|
|
95
|
+
}
|
|
96
|
+
function startsWithJsonCodec(bytes) {
|
|
97
|
+
return bytes.length >= 2 && bytes[0] === JSON_MULTICODEC[0] && bytes[1] === JSON_MULTICODEC[1];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Decode a did:peer:4 long form into its DID document.
|
|
101
|
+
* Verifies the embedded hash against the encoded doc; throws on mismatch.
|
|
102
|
+
*/ export function decodePeer4(longForm, options = {}) {
|
|
103
|
+
if (!isPeer4(longForm)) {
|
|
104
|
+
throw new Error('Invalid did:peer:4 identifier');
|
|
105
|
+
}
|
|
106
|
+
const sep = longForm.indexOf(':', PEER4_PREFIX.length);
|
|
107
|
+
if (sep === -1) {
|
|
108
|
+
throw new Error('Expected did:peer:4 long form, got short form');
|
|
109
|
+
}
|
|
110
|
+
const hashEncoded = longForm.slice(PEER4_PREFIX.length, sep);
|
|
111
|
+
const encodedDoc = longForm.slice(sep + 1);
|
|
112
|
+
const maxSize = options.maxDocSize ?? DEFAULT_MAX_DOC_SIZE;
|
|
113
|
+
if (encodedDoc.length > maxSize * 2) {
|
|
114
|
+
throw new Error(`did:peer:4 encoded doc too large: ${encodedDoc.length} > ${maxSize * 2}`);
|
|
115
|
+
}
|
|
116
|
+
const hashBytes = decodeMultibase(hashEncoded);
|
|
117
|
+
if (!verifyMultihash(hashBytes, fromUTF(encodedDoc))) {
|
|
118
|
+
throw new Error('did:peer:4 hash mismatch');
|
|
119
|
+
}
|
|
120
|
+
const taggedDoc = decodeMultibase(encodedDoc);
|
|
121
|
+
if (!startsWithJsonCodec(taggedDoc)) {
|
|
122
|
+
throw new Error('did:peer:4 doc missing JSON multicodec prefix');
|
|
123
|
+
}
|
|
124
|
+
const docBytes = taggedDoc.slice(JSON_MULTICODEC.length);
|
|
125
|
+
if (docBytes.length > maxSize) {
|
|
126
|
+
throw new Error(`did:peer:4 doc too large: ${docBytes.length} > ${maxSize}`);
|
|
127
|
+
}
|
|
128
|
+
const doc = JSON.parse(toUTF(docBytes));
|
|
129
|
+
if (!validateDIDDoc(doc)) {
|
|
130
|
+
throw new Error('did:peer:4 doc failed schema validation');
|
|
131
|
+
}
|
|
132
|
+
const shortForm = `${PEER4_PREFIX}${hashEncoded}`;
|
|
133
|
+
return {
|
|
134
|
+
longForm,
|
|
135
|
+
shortForm,
|
|
136
|
+
doc
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Encode a DID document as a did:peer:4 identifier.
|
|
141
|
+
* Returns both the long form (self-contained, doc embedded) and short form (hash only).
|
|
142
|
+
*/ export function encodePeer4(doc) {
|
|
143
|
+
if (!validateDIDDoc(doc)) {
|
|
144
|
+
throw new Error('did:peer:4 doc failed schema validation');
|
|
145
|
+
}
|
|
146
|
+
const canonicalDocBytes = fromUTF(canonicalStringify(doc));
|
|
147
|
+
const taggedDoc = concatBytes(JSON_MULTICODEC, canonicalDocBytes);
|
|
148
|
+
const encodedDoc = encodeMultibase(taggedDoc);
|
|
149
|
+
const hashBytes = multihashSHA256(fromUTF(encodedDoc));
|
|
150
|
+
const hash = encodeMultibase(hashBytes);
|
|
151
|
+
const shortForm = `${PEER4_PREFIX}${hash}`;
|
|
152
|
+
const longForm = `${shortForm}:${encodedDoc}`;
|
|
153
|
+
return {
|
|
154
|
+
longForm,
|
|
155
|
+
shortForm,
|
|
156
|
+
doc
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MultiKeyIdentity } from './identity.js';
|
|
2
|
+
import type { SignedToken } from './types.js';
|
|
3
|
+
export type RotationPayload = {
|
|
4
|
+
type: 'did-rotation';
|
|
5
|
+
iss?: string;
|
|
6
|
+
to: string;
|
|
7
|
+
toLongForm: string;
|
|
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 whose `iss` is the old DID.
|
|
13
|
+
* Verifiers walking a rotation chain can use this to link related identities.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createRotationAssertion(oldIdentity: MultiKeyIdentity, newIdentity: MultiKeyIdentity, issuedAt?: number): Promise<SignedToken<RotationPayload>>;
|
package/lib/rotation.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign a rotation assertion linking an old identity to a new one.
|
|
3
|
+
* The assertion is a regular signed token whose `iss` is the old DID.
|
|
4
|
+
* Verifiers walking a rotation chain can use this to link related identities.
|
|
5
|
+
*/ export async function createRotationAssertion(oldIdentity, newIdentity, issuedAt = Math.floor(Date.now() / 1000)) {
|
|
6
|
+
return oldIdentity.signToken({
|
|
7
|
+
type: 'did-rotation',
|
|
8
|
+
to: newIdentity.id,
|
|
9
|
+
toLongForm: newIdentity.longForm,
|
|
10
|
+
issuedAt
|
|
11
|
+
});
|
|
12
|
+
}
|