@interop/did-method-webvh 3.2.0 → 3.3.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/CHANGELOG.md +186 -0
- package/README.md +2 -11
- package/dist/assertions.js +19 -2
- package/dist/constants.d.ts +21 -0
- package/dist/constants.js +28 -0
- package/dist/cryptography.d.ts +5 -3
- package/dist/cryptography.js +4 -2
- package/dist/interfaces.d.ts +8 -7
- package/dist/method.d.ts +7 -5
- package/dist/method.js +3 -3
- package/dist/method_versions/method.v1.0.d.ts +6 -5
- package/dist/method_versions/method.v1.0.js +296 -133
- package/dist/utils/iso8601-datetime.d.ts +55 -0
- package/dist/utils/iso8601-datetime.js +116 -0
- package/dist/utils/multiformats.d.ts +2 -0
- package/dist/utils/multiformats.js +4 -0
- package/dist/utils.d.ts +15 -2
- package/dist/utils.js +182 -95
- package/dist/witness.js +12 -5
- package/package.json +2 -6
- package/dist/cli.d.ts +0 -21
- package/dist/cli.js +0 -533
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO 8601 DateTime Validation
|
|
3
|
+
*
|
|
4
|
+
* Inlined from iso-8601-regex (https://github.com/lightningspirit/iso-8601-regex-ts)
|
|
5
|
+
* Licensed under MIT by lightningspirit
|
|
6
|
+
*
|
|
7
|
+
* Enforces strict calendar correctness including leap-year rules and valid day ranges.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Strict ISO 8601 datetime regex with full calendar validation.
|
|
11
|
+
*
|
|
12
|
+
* Enforces:
|
|
13
|
+
* - Year: 0000–9999
|
|
14
|
+
* - Month: 01–12
|
|
15
|
+
* - Day: per month (01–31, 30-day months restricted, Feb 29 allowed only on leap years)
|
|
16
|
+
* - Hour: 00–23
|
|
17
|
+
* - Minute: 00–59
|
|
18
|
+
* - Second: 00–59
|
|
19
|
+
* - Milliseconds: optional, 1–3 digits
|
|
20
|
+
* - Timezone: "Z" (UTC) or ±HH:MM (offset -12:00…+14:00)
|
|
21
|
+
*
|
|
22
|
+
* Leap-year logic (Gregorian calendar):
|
|
23
|
+
* - Divisible by 4 → leap year
|
|
24
|
+
* - Divisible by 100 → not a leap year
|
|
25
|
+
* - Divisible by 400 → leap year again
|
|
26
|
+
*
|
|
27
|
+
* Format: YYYY-MM-DDTHH:mm:ss(.SSS)?(Z|±HH:MM)
|
|
28
|
+
*
|
|
29
|
+
* Valid examples:
|
|
30
|
+
* - "2025-11-02T10:20:30Z" // UTC
|
|
31
|
+
* - "2025-11-02T10:20:30.123Z" // With milliseconds
|
|
32
|
+
* - "2025-11-02T10:20:30+01:00" // With positive offset
|
|
33
|
+
* - "2024-02-29T12:00:00Z" // Leap year (Feb 29 allowed)
|
|
34
|
+
*
|
|
35
|
+
* Invalid examples:
|
|
36
|
+
* - "2025-11-02T10:20:30" // Missing timezone
|
|
37
|
+
* - "2025-04-31T12:00:00Z" // Invalid day for April
|
|
38
|
+
* - "1900-02-29T00:00:00Z" // 1900 not a leap year
|
|
39
|
+
* - "2025-11-02T24:00:00Z" // Invalid hour
|
|
40
|
+
*/
|
|
41
|
+
export declare const ISO8601_DATETIME_REGEX: RegExp;
|
|
42
|
+
/**
|
|
43
|
+
* Parse and validate UTC ISO8601 versionTime per did:webvh spec.
|
|
44
|
+
*
|
|
45
|
+
* Enforces:
|
|
46
|
+
* - Strict ISO 8601 format with calendar correctness (via regex)
|
|
47
|
+
* - Timezone MUST be explicit Z or +00:00 (per normative spec language)
|
|
48
|
+
* - Semantic date validity (via Date.parse)
|
|
49
|
+
*
|
|
50
|
+
* Spec reference: did:webvh v1.0 §3.5 and §3.6.2 (`versionTime` in UTC ISO8601)
|
|
51
|
+
* https://identity.foundation/didwebvh/v1.0/
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseUtcIso8601VersionTime(value: string, context: string): Date;
|
|
54
|
+
export declare function validateUtcIso8601NotInFuture(value: string, context: string, maxFutureSkewMs?: number, now?: Date): Date;
|
|
55
|
+
export declare function createNextVersionTime(previousVersionTime: string, requestedVersionTime: string | undefined, formatDate: (value: string | Date) => string): string;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO 8601 DateTime Validation
|
|
3
|
+
*
|
|
4
|
+
* Inlined from iso-8601-regex (https://github.com/lightningspirit/iso-8601-regex-ts)
|
|
5
|
+
* Licensed under MIT by lightningspirit
|
|
6
|
+
*
|
|
7
|
+
* Enforces strict calendar correctness including leap-year rules and valid day ranges.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Strict ISO 8601 datetime regex with full calendar validation.
|
|
11
|
+
*
|
|
12
|
+
* Enforces:
|
|
13
|
+
* - Year: 0000–9999
|
|
14
|
+
* - Month: 01–12
|
|
15
|
+
* - Day: per month (01–31, 30-day months restricted, Feb 29 allowed only on leap years)
|
|
16
|
+
* - Hour: 00–23
|
|
17
|
+
* - Minute: 00–59
|
|
18
|
+
* - Second: 00–59
|
|
19
|
+
* - Milliseconds: optional, 1–3 digits
|
|
20
|
+
* - Timezone: "Z" (UTC) or ±HH:MM (offset -12:00…+14:00)
|
|
21
|
+
*
|
|
22
|
+
* Leap-year logic (Gregorian calendar):
|
|
23
|
+
* - Divisible by 4 → leap year
|
|
24
|
+
* - Divisible by 100 → not a leap year
|
|
25
|
+
* - Divisible by 400 → leap year again
|
|
26
|
+
*
|
|
27
|
+
* Format: YYYY-MM-DDTHH:mm:ss(.SSS)?(Z|±HH:MM)
|
|
28
|
+
*
|
|
29
|
+
* Valid examples:
|
|
30
|
+
* - "2025-11-02T10:20:30Z" // UTC
|
|
31
|
+
* - "2025-11-02T10:20:30.123Z" // With milliseconds
|
|
32
|
+
* - "2025-11-02T10:20:30+01:00" // With positive offset
|
|
33
|
+
* - "2024-02-29T12:00:00Z" // Leap year (Feb 29 allowed)
|
|
34
|
+
*
|
|
35
|
+
* Invalid examples:
|
|
36
|
+
* - "2025-11-02T10:20:30" // Missing timezone
|
|
37
|
+
* - "2025-04-31T12:00:00Z" // Invalid day for April
|
|
38
|
+
* - "1900-02-29T00:00:00Z" // 1900 not a leap year
|
|
39
|
+
* - "2025-11-02T24:00:00Z" // Invalid hour
|
|
40
|
+
*/
|
|
41
|
+
export const ISO8601_DATETIME_REGEX = new RegExp('^' +
|
|
42
|
+
'(?<year>\\d{4})-' +
|
|
43
|
+
'(?<month>(?:0[1-9]|1[0-2]))-' +
|
|
44
|
+
'(?<day>' +
|
|
45
|
+
'(?:' +
|
|
46
|
+
'(?<=\\d{4}-(?:01|03|05|07|08|10|12)-)(?:0[1-9]|[12]\\d|3[01])|' + // 31-day months
|
|
47
|
+
'(?<=\\d{4}-(?:04|06|09|11)-)(?:0[1-9]|[12]\\d|30)|' + // 30-day months
|
|
48
|
+
'(?<=\\d{4}-02-)(?:0[1-9]|1\\d|2[0-8])|' + // Feb 01-28
|
|
49
|
+
'(?<=(' +
|
|
50
|
+
'(?:\\d{2}(?:0[48]|[2468][048]|[13579][26]))' + // yy % 4 == 0 (leap year non-century)
|
|
51
|
+
'|(?:(?:[02468][048]|[13579][26])00)' + // centuries % 400 == 0 (leap year century)
|
|
52
|
+
')-02-)29' + // Feb 29 only if preceding matches leap year
|
|
53
|
+
')' +
|
|
54
|
+
')' +
|
|
55
|
+
'T' +
|
|
56
|
+
'(?<hour>(?:[01]\\d|2[0-3])):' +
|
|
57
|
+
'(?<minute>[0-5]\\d):' +
|
|
58
|
+
'(?<second>[0-5]\\d)' +
|
|
59
|
+
'(?:\\.(?<millisecond>\\d{1,3}))?' + // optional .sss
|
|
60
|
+
'(?<timezone>' +
|
|
61
|
+
'Z|' + // UTC
|
|
62
|
+
'(?:' +
|
|
63
|
+
'\\+(?:(?:0\\d|1[0-3]):[0-5]\\d|14:00)|' + // +00:00…+13:59 or +14:00
|
|
64
|
+
'-(?:(?:0\\d|1[01]):[0-5]\\d|12:00)' + // -00:00…-11:59 or -12:00
|
|
65
|
+
')' +
|
|
66
|
+
')' +
|
|
67
|
+
'$');
|
|
68
|
+
/**
|
|
69
|
+
* Parse and validate UTC ISO8601 versionTime per did:webvh spec.
|
|
70
|
+
*
|
|
71
|
+
* Enforces:
|
|
72
|
+
* - Strict ISO 8601 format with calendar correctness (via regex)
|
|
73
|
+
* - Timezone MUST be explicit Z or +00:00 (per normative spec language)
|
|
74
|
+
* - Semantic date validity (via Date.parse)
|
|
75
|
+
*
|
|
76
|
+
* Spec reference: did:webvh v1.0 §3.5 and §3.6.2 (`versionTime` in UTC ISO8601)
|
|
77
|
+
* https://identity.foundation/didwebvh/v1.0/
|
|
78
|
+
*/
|
|
79
|
+
export function parseUtcIso8601VersionTime(value, context) {
|
|
80
|
+
const match = ISO8601_DATETIME_REGEX.exec(value);
|
|
81
|
+
const parsed = new Date(value);
|
|
82
|
+
if (!match || Number.isNaN(parsed.getTime())) {
|
|
83
|
+
throw new Error(`${context} must be a valid UTC ISO8601 timestamp`);
|
|
84
|
+
}
|
|
85
|
+
// Per spec, only Z or +00:00 (explicit UTC) are allowed
|
|
86
|
+
const timezone = match.groups?.timezone;
|
|
87
|
+
if (timezone && timezone !== 'Z' && timezone !== '+00:00') {
|
|
88
|
+
throw new Error(`${context} must be in UTC (Z or +00:00), found ${timezone}`);
|
|
89
|
+
}
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
export function validateUtcIso8601NotInFuture(value, context, maxFutureSkewMs = 0, now = new Date()) {
|
|
93
|
+
const parsed = parseUtcIso8601VersionTime(value, context);
|
|
94
|
+
if (parsed.getTime() > now.getTime() + maxFutureSkewMs) {
|
|
95
|
+
if (maxFutureSkewMs > 0) {
|
|
96
|
+
throw new Error(`${context} must not be more than ${maxFutureSkewMs / 60000} minutes in the future`);
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`${context} must not be in the future`);
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
export function createNextVersionTime(previousVersionTime, requestedVersionTime, formatDate) {
|
|
103
|
+
const previous = parseUtcIso8601VersionTime(previousVersionTime, 'previous versionTime');
|
|
104
|
+
if (requestedVersionTime) {
|
|
105
|
+
const requested = parseUtcIso8601VersionTime(requestedVersionTime, 'requested versionTime');
|
|
106
|
+
if (requested.getTime() <= previous.getTime()) {
|
|
107
|
+
throw new Error('versionTime must be greater than previous versionTime');
|
|
108
|
+
}
|
|
109
|
+
return formatDate(requestedVersionTime);
|
|
110
|
+
}
|
|
111
|
+
const now = new Date();
|
|
112
|
+
if (now.getTime() <= previous.getTime()) {
|
|
113
|
+
return formatDate(new Date(previous.getTime() + 1));
|
|
114
|
+
}
|
|
115
|
+
return formatDate(now);
|
|
116
|
+
}
|
|
@@ -98,3 +98,5 @@ export declare function decodeMultihashFromMultibase(str: string): {
|
|
|
98
98
|
digest: Uint8Array;
|
|
99
99
|
encoding: MultibaseEncoding;
|
|
100
100
|
};
|
|
101
|
+
/** Multicodec prefix (`0xed 0x01`) for an Ed25519 public key multikey. */
|
|
102
|
+
export declare function isEd25519Multikey(keyBytes: Uint8Array): boolean;
|
|
@@ -281,3 +281,7 @@ export function decodeMultihashFromMultibase(str) {
|
|
|
281
281
|
encoding,
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
|
+
/** Multicodec prefix (`0xed 0x01`) for an Ed25519 public key multikey. */
|
|
285
|
+
export function isEd25519Multikey(keyBytes) {
|
|
286
|
+
return keyBytes.length >= 2 && keyBytes[0] === 0xed && keyBytes[1] === 0x01;
|
|
287
|
+
}
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateDIDInterface, DIDDoc, DIDLog, ParsedDidKeyVerificationMethod, VerificationMethod, WitnessProofFileEntry } from './interfaces.js';
|
|
1
|
+
import type { CreateDIDInterface, DIDDoc, DIDLog, ParsedDidKeyVerificationMethod, ServiceEndpoint, VerificationMethod, WitnessProofFileEntry } from './interfaces.js';
|
|
2
2
|
export declare function parseDidKeyDid(input: string): {
|
|
3
3
|
did: string;
|
|
4
4
|
keyMultibase: string;
|
|
@@ -10,7 +10,15 @@ interface ParsedAddress {
|
|
|
10
10
|
didDomainComponent: string;
|
|
11
11
|
paths?: string[];
|
|
12
12
|
}
|
|
13
|
+
export interface ParsedDidWebvhIdentifier {
|
|
14
|
+
scid: string;
|
|
15
|
+
didDomainComponent: string;
|
|
16
|
+
paths?: string[];
|
|
17
|
+
locationKey: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function validateMethodSpecificPathSegments(pathSegments: string[], context: string): void;
|
|
13
20
|
export declare function parseCanonicalAddress(input: string): ParsedAddress;
|
|
21
|
+
export declare function parseDidWebvhIdentifier(did: string, context: string): ParsedDidWebvhIdentifier;
|
|
14
22
|
export declare const DID_PLACEHOLDER = "{DID}";
|
|
15
23
|
export declare function validateCreateDidDocument(didDocument: DIDDoc): void;
|
|
16
24
|
export declare function replaceCreateDidPlaceholders<T>(input: T, scid: string, did: string): T;
|
|
@@ -18,6 +26,11 @@ export declare function convertWebvhIdToWebId(id: string): string;
|
|
|
18
26
|
export declare function enrichAlsoKnownAs(doc: DIDDoc, did: string, opts: {
|
|
19
27
|
alsoKnownAsWeb?: boolean;
|
|
20
28
|
}): DIDDoc;
|
|
29
|
+
/**
|
|
30
|
+
* Check if a service with the given fragment exists in the service array.
|
|
31
|
+
* Matches both fragment form (e.g., '#files') and absolute form (e.g., 'did:webvh:...#files').
|
|
32
|
+
*/
|
|
33
|
+
export declare function serviceFragmentExists(services: ServiceEndpoint[], fragment: string, did: string): boolean;
|
|
21
34
|
export declare function generateParallelDidWeb(didwebvhDid: string, didwebvhDoc: DIDDoc): DIDDoc;
|
|
22
35
|
export declare const readLogFromDisk: (path: string) => Promise<DIDLog>;
|
|
23
36
|
export declare const readLogFromString: (str: string) => DIDLog;
|
|
@@ -43,7 +56,7 @@ export declare const normalizeVMs: (verificationMethod: VerificationMethod[] | u
|
|
|
43
56
|
export declare const resolveVM: (vm: string) => Promise<VerificationMethod | {
|
|
44
57
|
publicKeyMultibase: string;
|
|
45
58
|
} | null>;
|
|
46
|
-
export declare const findVerificationMethod: (doc:
|
|
59
|
+
export declare const findVerificationMethod: (doc: DIDDoc, vmId: string) => VerificationMethod | null;
|
|
47
60
|
export declare function fetchWitnessProofs(did: string): Promise<WitnessProofFileEntry[]>;
|
|
48
61
|
export declare function replaceValueInObject(obj: any, searchValue: string, replaceValue: string): any;
|
|
49
62
|
export {};
|
package/dist/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { config } from './config.js';
|
|
2
|
-
import { BASE_CONTEXT } from './constants.js';
|
|
2
|
+
import { BASE_CONTEXT, CONTEXT_LINKED_VP, METHOD, SERVICE_TYPE_LINKED_VP, SERVICE_TYPE_RELATIVE_REF, } from './constants.js';
|
|
3
3
|
import { resolveDIDFromLog } from './method.js';
|
|
4
4
|
import { bufferToString, createBuffer } from './utils/buffer.js';
|
|
5
5
|
import { canonicalizeStrict } from './utils/canonicalize.js';
|
|
@@ -14,7 +14,8 @@ function validateDidKeyMultibase(keyMultibase) {
|
|
|
14
14
|
multibaseDecode(keyMultibase);
|
|
15
15
|
}
|
|
16
16
|
catch (error) {
|
|
17
|
-
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(`Malformed did:key identifier: ${message}`);
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
export function parseDidKeyDid(input) {
|
|
@@ -45,6 +46,11 @@ export function parseDidKeyVerificationMethod(input) {
|
|
|
45
46
|
}
|
|
46
47
|
const parsedDid = parseDidKeyDid(`${DID_KEY_PREFIX}${match[1]}`);
|
|
47
48
|
const fragment = match[2];
|
|
49
|
+
// If fragment is present, it MUST equal the body multibase exactly
|
|
50
|
+
if (fragment && fragment !== parsedDid.keyMultibase) {
|
|
51
|
+
throw new Error(`did:key verificationMethod fragment must equal body multibase. ` +
|
|
52
|
+
`Expected fragment '${parsedDid.keyMultibase}' but got '${fragment}'`);
|
|
53
|
+
}
|
|
48
54
|
return {
|
|
49
55
|
did: parsedDid.did,
|
|
50
56
|
fragment,
|
|
@@ -65,44 +71,120 @@ function isDoubleEncoded(value) {
|
|
|
65
71
|
// Detect %25 (which is percent-encoded %)
|
|
66
72
|
return value.includes('%25');
|
|
67
73
|
}
|
|
74
|
+
function hasFragmentOrQuery(value) {
|
|
75
|
+
return value.includes('#') || value.includes('?');
|
|
76
|
+
}
|
|
77
|
+
function decodeHostComponent(host) {
|
|
78
|
+
try {
|
|
79
|
+
return decodeURIComponent(host);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error(`Invalid percent-encoding in host: ${host}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function parsePortNumber(rawPort) {
|
|
86
|
+
const portNum = parseInt(rawPort, 10);
|
|
87
|
+
if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
|
|
88
|
+
throw new Error(`Invalid port number: ${rawPort}`);
|
|
89
|
+
}
|
|
90
|
+
return portNum;
|
|
91
|
+
}
|
|
92
|
+
function toOptionalPaths(pathSegments) {
|
|
93
|
+
return pathSegments.length > 0 ? pathSegments : undefined;
|
|
94
|
+
}
|
|
95
|
+
function toDidDomainComponent(host, port) {
|
|
96
|
+
return port ? `${host}%3A${port}` : host;
|
|
97
|
+
}
|
|
98
|
+
function toParsedAddress(host, port, paths = []) {
|
|
99
|
+
return {
|
|
100
|
+
canonicalHost: host,
|
|
101
|
+
canonicalPort: port,
|
|
102
|
+
didDomainComponent: toDidDomainComponent(host, port),
|
|
103
|
+
paths: toOptionalPaths(paths),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function parseRawHostPort(input) {
|
|
107
|
+
if (!input.includes(':')) {
|
|
108
|
+
return { host: input };
|
|
109
|
+
}
|
|
110
|
+
const parts = input.split(':');
|
|
111
|
+
if (parts.length !== 2) {
|
|
112
|
+
throw new Error('Invalid host:port format');
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
host: parts[0],
|
|
116
|
+
port: parsePortNumber(parts[1]),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function parseEncodedPortComponent(value) {
|
|
120
|
+
const encodedSeparator = /%3a/i;
|
|
121
|
+
if (!encodedSeparator.test(value)) {
|
|
122
|
+
return { host: value };
|
|
123
|
+
}
|
|
124
|
+
const parts = value.split(encodedSeparator);
|
|
125
|
+
if (parts.length !== 2) {
|
|
126
|
+
throw new Error('Invalid pre-encoded port separator');
|
|
127
|
+
}
|
|
128
|
+
const [host, rawPort] = parts;
|
|
129
|
+
return { host, port: parsePortNumber(rawPort) };
|
|
130
|
+
}
|
|
131
|
+
export function validateMethodSpecificPathSegments(pathSegments, context) {
|
|
132
|
+
for (const segment of pathSegments) {
|
|
133
|
+
let decodedSegment;
|
|
134
|
+
try {
|
|
135
|
+
decodedSegment = decodeURIComponent(segment);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error(`${context} contains invalid percent-encoding in path segment '${segment}'`);
|
|
139
|
+
}
|
|
140
|
+
if (decodedSegment === '.' || decodedSegment === '..') {
|
|
141
|
+
throw new Error(`${context} must not contain dot-segments`);
|
|
142
|
+
}
|
|
143
|
+
if (decodedSegment.includes('/')) {
|
|
144
|
+
throw new Error(`${context} must not contain decoded slash within a single path segment`);
|
|
145
|
+
}
|
|
146
|
+
if (decodedSegment.includes('\\')) {
|
|
147
|
+
throw new Error(`${context} must not contain decoded backslash within a path segment`);
|
|
148
|
+
}
|
|
149
|
+
if (decodedSegment.includes('\u0000')) {
|
|
150
|
+
throw new Error(`${context} must not contain decoded NUL character within a path segment`);
|
|
151
|
+
}
|
|
152
|
+
if (decodedSegment !== decodedSegment.trim()) {
|
|
153
|
+
throw new Error(`${context} must not contain leading or trailing whitespace in decoded path segment`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
68
157
|
export function parseCanonicalAddress(input) {
|
|
69
158
|
if (!input || typeof input !== 'string') {
|
|
70
159
|
throw new Error('Address input must be a non-empty string');
|
|
71
160
|
}
|
|
161
|
+
if (hasFragmentOrQuery(input) && !input.startsWith('http://') && !input.startsWith('https://')) {
|
|
162
|
+
throw new Error('Address input must not include query or fragment components');
|
|
163
|
+
}
|
|
72
164
|
// Parse did:webvh form
|
|
73
165
|
if (input.startsWith('did:webvh:')) {
|
|
74
166
|
const parts = input.substring(10).split(':');
|
|
75
167
|
if (parts.length < 2) {
|
|
76
168
|
throw new Error('Invalid did:webvh identifier: must contain SCID (or {SCID} placeholder) and domain');
|
|
77
169
|
}
|
|
78
|
-
const scid = parts[0];
|
|
79
170
|
const domainPart = parts[1];
|
|
80
171
|
const pathParts = parts.slice(2);
|
|
172
|
+
if (hasFragmentOrQuery(domainPart) || pathParts.some((segment) => hasFragmentOrQuery(segment))) {
|
|
173
|
+
throw new Error('did:webvh identifier must not include query or fragment components');
|
|
174
|
+
}
|
|
175
|
+
validateMethodSpecificPathSegments(pathParts, 'did:webvh identifier');
|
|
81
176
|
// Detect double encoding
|
|
82
177
|
if (isDoubleEncoded(domainPart)) {
|
|
83
178
|
throw new Error('Domain is double-encoded (detected %25)');
|
|
84
179
|
}
|
|
85
180
|
// Extract port from domain if %3A-encoded
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const [h, p] = domainPart.split('%3A');
|
|
90
|
-
host = h;
|
|
91
|
-
const portNum = parseInt(p, 10);
|
|
92
|
-
if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
|
|
93
|
-
throw new Error(`Invalid port number: ${p}`);
|
|
94
|
-
}
|
|
95
|
-
port = portNum;
|
|
96
|
-
}
|
|
181
|
+
const parsedPort = parseEncodedPortComponent(domainPart);
|
|
182
|
+
const host = decodeHostComponent(parsedPort.host);
|
|
183
|
+
const port = parsedPort.port;
|
|
97
184
|
if (isIPAddress(host)) {
|
|
98
185
|
throw new Error('IP addresses are not allowed as hosts');
|
|
99
186
|
}
|
|
100
|
-
return
|
|
101
|
-
canonicalHost: host,
|
|
102
|
-
canonicalPort: port,
|
|
103
|
-
didDomainComponent: domainPart,
|
|
104
|
-
paths: pathParts.length > 0 ? pathParts : undefined,
|
|
105
|
-
};
|
|
187
|
+
return toParsedAddress(host, port, pathParts);
|
|
106
188
|
}
|
|
107
189
|
// Parse URL form: HTTPS everywhere, with localhost-only HTTP for local testing.
|
|
108
190
|
if (input.startsWith('https://') || input.startsWith('http://')) {
|
|
@@ -111,35 +193,23 @@ export function parseCanonicalAddress(input) {
|
|
|
111
193
|
if (url.protocol === 'http:' && url.hostname !== 'localhost') {
|
|
112
194
|
throw new Error('HTTP is only allowed for localhost; use HTTPS for non-local hosts');
|
|
113
195
|
}
|
|
196
|
+
if (url.hash || url.search) {
|
|
197
|
+
throw new Error('URL input must not include query or fragment components');
|
|
198
|
+
}
|
|
114
199
|
const host = url.hostname;
|
|
115
200
|
const port = url.port ? parseInt(url.port, 10) : undefined;
|
|
116
201
|
if (isIPAddress(host)) {
|
|
117
202
|
throw new Error('IP addresses are not allowed as hosts');
|
|
118
203
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
const pathParts = [];
|
|
124
|
-
if (url.pathname && url.pathname !== '/') {
|
|
125
|
-
url.pathname
|
|
126
|
-
.split('/')
|
|
127
|
-
.filter((p) => p.length > 0)
|
|
128
|
-
.forEach((p) => {
|
|
129
|
-
pathParts.push(p);
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
return {
|
|
133
|
-
canonicalHost: host,
|
|
134
|
-
canonicalPort: port,
|
|
135
|
-
didDomainComponent,
|
|
136
|
-
paths: pathParts.length > 0 ? pathParts : undefined,
|
|
137
|
-
};
|
|
204
|
+
const pathParts = url.pathname && url.pathname !== '/' ? url.pathname.split('/').filter((p) => p.length > 0) : [];
|
|
205
|
+
validateMethodSpecificPathSegments(pathParts, 'URL pathname');
|
|
206
|
+
return toParsedAddress(host, port, pathParts);
|
|
138
207
|
}
|
|
139
208
|
catch (e) {
|
|
140
|
-
|
|
209
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
210
|
+
if (message.includes('not allowed'))
|
|
141
211
|
throw e;
|
|
142
|
-
throw new Error(`Invalid URL: ${
|
|
212
|
+
throw new Error(`Invalid URL: ${message}`);
|
|
143
213
|
}
|
|
144
214
|
}
|
|
145
215
|
// Parse domain string form (host or host:port)
|
|
@@ -147,45 +217,35 @@ export function parseCanonicalAddress(input) {
|
|
|
147
217
|
if (isDoubleEncoded(input)) {
|
|
148
218
|
throw new Error('Domain is double-encoded (detected %25)');
|
|
149
219
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Check if pre-encoded with %3A
|
|
153
|
-
if (input.includes('%3A')) {
|
|
154
|
-
const parts = input.split('%3A');
|
|
155
|
-
if (parts.length !== 2) {
|
|
156
|
-
throw new Error('Invalid pre-encoded port separator');
|
|
157
|
-
}
|
|
158
|
-
host = parts[0];
|
|
159
|
-
const portNum = parseInt(parts[1], 10);
|
|
160
|
-
if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
|
|
161
|
-
throw new Error(`Invalid port number: ${parts[1]}`);
|
|
162
|
-
}
|
|
163
|
-
port = portNum;
|
|
164
|
-
}
|
|
165
|
-
else if (input.includes(':')) {
|
|
166
|
-
// Raw host:port form
|
|
167
|
-
const parts = input.split(':');
|
|
168
|
-
if (parts.length !== 2) {
|
|
169
|
-
throw new Error('Invalid host:port format');
|
|
170
|
-
}
|
|
171
|
-
host = parts[0];
|
|
172
|
-
const portNum = parseInt(parts[1], 10);
|
|
173
|
-
if (Number.isNaN(portNum) || portNum <= 0 || portNum > 65535) {
|
|
174
|
-
throw new Error(`Invalid port number: ${parts[1]}`);
|
|
175
|
-
}
|
|
176
|
-
port = portNum;
|
|
220
|
+
if (hasFragmentOrQuery(input)) {
|
|
221
|
+
throw new Error('Domain input must not include query or fragment components');
|
|
177
222
|
}
|
|
223
|
+
const hostAndPort = /%3a/i.test(input) ? parseEncodedPortComponent(input) : parseRawHostPort(input);
|
|
224
|
+
const host = decodeHostComponent(hostAndPort.host);
|
|
225
|
+
const port = hostAndPort.port;
|
|
178
226
|
if (isIPAddress(host)) {
|
|
179
227
|
throw new Error('IP addresses are not allowed as hosts');
|
|
180
228
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
229
|
+
return toParsedAddress(host, port);
|
|
230
|
+
}
|
|
231
|
+
export function parseDidWebvhIdentifier(did, context) {
|
|
232
|
+
const parsedAddress = parseCanonicalAddress(did);
|
|
233
|
+
const didParts = did.split(':');
|
|
234
|
+
if (didParts.length < 4 || didParts[0] !== 'did' || didParts[1] !== METHOD) {
|
|
235
|
+
throw new Error(`${context} must be a valid did:webvh identifier`);
|
|
236
|
+
}
|
|
237
|
+
const scid = didParts[2];
|
|
238
|
+
if (!scid) {
|
|
239
|
+
throw new Error(`${context} must include SCID segment`);
|
|
240
|
+
}
|
|
241
|
+
const locationKey = parsedAddress.paths?.length
|
|
242
|
+
? `${parsedAddress.didDomainComponent}:${parsedAddress.paths.join(':')}`
|
|
243
|
+
: parsedAddress.didDomainComponent;
|
|
185
244
|
return {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
245
|
+
scid,
|
|
246
|
+
didDomainComponent: parsedAddress.didDomainComponent,
|
|
247
|
+
paths: parsedAddress.paths,
|
|
248
|
+
locationKey,
|
|
189
249
|
};
|
|
190
250
|
}
|
|
191
251
|
// Environment detection - treat React Native like a browser, but Bun as Node-like
|
|
@@ -252,6 +312,18 @@ export function enrichAlsoKnownAs(doc, did, opts) {
|
|
|
252
312
|
alsoKnownAs: aliases,
|
|
253
313
|
};
|
|
254
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Check if a service with the given fragment exists in the service array.
|
|
317
|
+
* Matches both fragment form (e.g., '#files') and absolute form (e.g., 'did:webvh:...#files').
|
|
318
|
+
*/
|
|
319
|
+
export function serviceFragmentExists(services, fragment, did) {
|
|
320
|
+
const fragmentForm = `#${fragment}`;
|
|
321
|
+
const absoluteForm = `${did}#${fragment}`;
|
|
322
|
+
return services.some((s) => {
|
|
323
|
+
const serviceId = s.id || '';
|
|
324
|
+
return serviceId === fragmentForm || serviceId === absoluteForm;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
255
327
|
export function generateParallelDidWeb(didwebvhDid, didwebvhDoc) {
|
|
256
328
|
let webDoc = deepClone(didwebvhDoc);
|
|
257
329
|
const domainPath = didwebvhDid.replace(/^did:webvh:[^:]+:/, '');
|
|
@@ -261,15 +333,15 @@ export function generateParallelDidWeb(didwebvhDid, didwebvhDoc) {
|
|
|
261
333
|
if (!existingServiceIds.some((id) => id.endsWith('#files'))) {
|
|
262
334
|
implicitServices.push({
|
|
263
335
|
id: '#files',
|
|
264
|
-
type:
|
|
336
|
+
type: SERVICE_TYPE_RELATIVE_REF,
|
|
265
337
|
serviceEndpoint: httpsBase,
|
|
266
338
|
});
|
|
267
339
|
}
|
|
268
340
|
if (!existingServiceIds.some((id) => id.endsWith('#whois'))) {
|
|
269
341
|
implicitServices.push({
|
|
270
|
-
'@context':
|
|
342
|
+
'@context': CONTEXT_LINKED_VP,
|
|
271
343
|
id: '#whois',
|
|
272
|
-
type:
|
|
344
|
+
type: SERVICE_TYPE_LINKED_VP,
|
|
273
345
|
serviceEndpoint: `${httpsBase}whois.vp`,
|
|
274
346
|
});
|
|
275
347
|
}
|
|
@@ -345,7 +417,8 @@ export const writeVerificationMethodToEnv = async (verificationMethod) => {
|
|
|
345
417
|
const match = envContent.match(/DID_VERIFICATION_METHODS=(.*)/);
|
|
346
418
|
if (match?.[1]) {
|
|
347
419
|
const decodedData = bufferToString(createBuffer(match[1], 'base64'));
|
|
348
|
-
|
|
420
|
+
const parsedData = JSON.parse(decodedData);
|
|
421
|
+
existingData = Array.isArray(parsedData) ? parsedData : [];
|
|
349
422
|
// Check if verification method with same ID already exists
|
|
350
423
|
const existingIndex = existingData.findIndex((vm) => vm.id === vmData.id);
|
|
351
424
|
if (existingIndex !== -1) {
|
|
@@ -398,17 +471,15 @@ export function deepClone(obj) {
|
|
|
398
471
|
return cloned;
|
|
399
472
|
}
|
|
400
473
|
export const getBaseUrl = (id) => {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const protocol =
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const normalizedHost = port ? `${host}:${port}` : host;
|
|
411
|
-
const path = pathParts.join('/');
|
|
474
|
+
if (hasFragmentOrQuery(id)) {
|
|
475
|
+
throw new Error('did:webvh identifier must not include query or fragment components');
|
|
476
|
+
}
|
|
477
|
+
const parsed = parseCanonicalAddress(id);
|
|
478
|
+
// This fork allows http for localhost (local testing); HTTPS everywhere else.
|
|
479
|
+
const protocol = parsed.canonicalHost === 'localhost' ? 'http' : 'https';
|
|
480
|
+
const host = toASCII(parsed.canonicalHost.normalize('NFC'));
|
|
481
|
+
const normalizedHost = parsed.canonicalPort ? `${host}:${parsed.canonicalPort}` : host;
|
|
482
|
+
const path = parsed.paths?.join('/') ?? '';
|
|
412
483
|
return `${protocol}://${normalizedHost}${path ? `/${path}` : ''}`;
|
|
413
484
|
};
|
|
414
485
|
export const getFileUrl = (id) => {
|
|
@@ -437,7 +508,7 @@ export async function fetchLogFromIdentifier(identifier) {
|
|
|
437
508
|
throw error;
|
|
438
509
|
}
|
|
439
510
|
}
|
|
440
|
-
export const createDate = (created) =>
|
|
511
|
+
export const createDate = (created) => new Date(created ?? Date.now()).toISOString();
|
|
441
512
|
export function bytesToHex(bytes) {
|
|
442
513
|
return Array.from(bytes)
|
|
443
514
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
@@ -608,6 +679,9 @@ export const resolveVM = async (vm) => {
|
|
|
608
679
|
.split('\n')
|
|
609
680
|
.map((l) => JSON.parse(l));
|
|
610
681
|
const { doc } = await resolveDIDFromLog(logEntries, { verificationMethod: vm });
|
|
682
|
+
if (!doc) {
|
|
683
|
+
throw new Error(`Verification method ${vm} not found`);
|
|
684
|
+
}
|
|
611
685
|
return findVerificationMethod(doc, vm);
|
|
612
686
|
}
|
|
613
687
|
throw new Error(`Verification method ${vm} not found`);
|
|
@@ -619,7 +693,7 @@ export const resolveVM = async (vm) => {
|
|
|
619
693
|
export const findVerificationMethod = (doc, vmId) => {
|
|
620
694
|
// Check in the verificationMethod array
|
|
621
695
|
if (doc.verificationMethod?.some((vm) => vm.id === vmId)) {
|
|
622
|
-
return doc.verificationMethod.find((vm) => vm.id === vmId);
|
|
696
|
+
return doc.verificationMethod.find((vm) => vm.id === vmId) ?? null;
|
|
623
697
|
}
|
|
624
698
|
// Check in other verification method relationship arrays
|
|
625
699
|
const vmRelationships = [
|
|
@@ -630,9 +704,22 @@ export const findVerificationMethod = (doc, vmId) => {
|
|
|
630
704
|
'capabilityDelegation',
|
|
631
705
|
];
|
|
632
706
|
for (const relationship of vmRelationships) {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
707
|
+
const relationshipValues = doc[relationship];
|
|
708
|
+
if (Array.isArray(relationshipValues) &&
|
|
709
|
+
relationshipValues.some((item) => {
|
|
710
|
+
if (typeof item !== 'object' || item === null)
|
|
711
|
+
return false;
|
|
712
|
+
const maybeId = item.id;
|
|
713
|
+
return maybeId === vmId;
|
|
714
|
+
})) {
|
|
715
|
+
const match = relationshipValues.find((item) => {
|
|
716
|
+
if (typeof item !== 'object' || item === null)
|
|
717
|
+
return false;
|
|
718
|
+
const maybeId = item.id;
|
|
719
|
+
return maybeId === vmId;
|
|
720
|
+
});
|
|
721
|
+
if (match && typeof match === 'object') {
|
|
722
|
+
return match;
|
|
636
723
|
}
|
|
637
724
|
}
|
|
638
725
|
}
|