@tinycloud/sdk-core 3.8.1-beta.2 → 3.9.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/held-credentials/index.cjs +286 -116
- package/dist/held-credentials/index.cjs.map +1 -1
- package/dist/held-credentials/index.d.cts +117 -40
- package/dist/held-credentials/index.d.ts +117 -40
- package/dist/held-credentials/index.js +286 -116
- package/dist/held-credentials/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -456,86 +456,7 @@ function parseIssuerSet(raw, options) {
|
|
|
456
456
|
return new IssuerSet(CONSTRUCT, raw.version, entries);
|
|
457
457
|
}
|
|
458
458
|
|
|
459
|
-
// src/held-credentials/
|
|
460
|
-
var PRESENTATION_STATEMENT = "Present a TinyCloud held credential";
|
|
461
|
-
var SD_HASH_RESOURCE_PREFIX = "urn:tinycloud:sd-hash:";
|
|
462
|
-
var RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:Z|([+-])(\d{2}):(\d{2}))$/;
|
|
463
|
-
function parseRfc3339(value) {
|
|
464
|
-
const m = RFC3339.exec(value);
|
|
465
|
-
if (!m) return null;
|
|
466
|
-
const [year, month, day, hour, minute, second] = [m[1], m[2], m[3], m[4], m[5], m[6]].map(Number);
|
|
467
|
-
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return null;
|
|
468
|
-
const date = /* @__PURE__ */ new Date(0);
|
|
469
|
-
date.setUTCFullYear(year, month - 1, day);
|
|
470
|
-
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
|
|
471
|
-
let offsetMinutes = 0;
|
|
472
|
-
if (m[8] !== void 0) {
|
|
473
|
-
const oh = Number(m[9]), om = Number(m[10]);
|
|
474
|
-
if (oh > 23 || om > 59) return null;
|
|
475
|
-
offsetMinutes = (m[8] === "-" ? -1 : 1) * (oh * 60 + om);
|
|
476
|
-
}
|
|
477
|
-
const millis = m[7] === void 0 ? 0 : Number(m[7].padEnd(3, "0").slice(0, 3));
|
|
478
|
-
date.setUTCHours(hour, minute, second, millis);
|
|
479
|
-
return date.getTime() - offsetMinutes * 6e4;
|
|
480
|
-
}
|
|
481
|
-
var AUDIENCE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::([1-9][0-9]{0,4}))?$/;
|
|
482
|
-
function assertValidAudience(audience) {
|
|
483
|
-
const match = AUDIENCE.exec(audience);
|
|
484
|
-
if (!match || match[1] !== void 0 && (Number(match[1]) > 65535 || match[1] === "443")) {
|
|
485
|
-
refuse("bad-config", "audience must be a lowercase host[:port] authority without a default port");
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
function parseStrictSiwe(message) {
|
|
489
|
-
const bad = (why) => refuse("bad-siwe", why);
|
|
490
|
-
const lines = message.split("\n");
|
|
491
|
-
const head = /^(\S+) wants you to sign in with your Ethereum account:$/.exec(lines[0] ?? "");
|
|
492
|
-
if (!head) bad("header line");
|
|
493
|
-
const address = lines[1] ?? "";
|
|
494
|
-
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) bad("address");
|
|
495
|
-
if (toChecksumAddress(address) !== address) bad("address must be EIP-55 checksummed");
|
|
496
|
-
if (lines[2] !== "") bad("layout");
|
|
497
|
-
const statement = lines[3];
|
|
498
|
-
if (statement === void 0 || statement === "") bad("statement");
|
|
499
|
-
if (lines[4] !== "") bad("layout");
|
|
500
|
-
let index = 5;
|
|
501
|
-
const field = (name, optional = false) => {
|
|
502
|
-
const line = lines[index];
|
|
503
|
-
const prefix = `${name}: `;
|
|
504
|
-
if (line === void 0 || !line.startsWith(prefix)) {
|
|
505
|
-
if (optional) return void 0;
|
|
506
|
-
return bad(`missing ${name}`);
|
|
507
|
-
}
|
|
508
|
-
index += 1;
|
|
509
|
-
const value = line.slice(prefix.length);
|
|
510
|
-
if (value === "") bad(`empty ${name}`);
|
|
511
|
-
return value;
|
|
512
|
-
};
|
|
513
|
-
const uri = field("URI");
|
|
514
|
-
const version = field("Version");
|
|
515
|
-
const chainId = field("Chain ID");
|
|
516
|
-
const nonce = field("Nonce");
|
|
517
|
-
const issuedAt = field("Issued At");
|
|
518
|
-
const expirationTime = field("Expiration Time", true);
|
|
519
|
-
const notBefore = field("Not Before", true);
|
|
520
|
-
if (lines[index]?.startsWith("Request ID: ")) bad("Request ID is not accepted");
|
|
521
|
-
const resources = [];
|
|
522
|
-
if (lines[index] === "Resources:") {
|
|
523
|
-
index += 1;
|
|
524
|
-
while (lines[index] !== void 0 && lines[index].startsWith("- ")) {
|
|
525
|
-
resources.push(lines[index].slice(2));
|
|
526
|
-
index += 1;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
if (index !== lines.length) bad("trailing content");
|
|
530
|
-
if (!/^[0-9]+$/.test(chainId)) bad("chain id");
|
|
531
|
-
if (!/^[A-Za-z0-9]{8,}$/.test(nonce)) bad("nonce must be alphanumeric");
|
|
532
|
-
for (const [name, value] of [["Issued At", issuedAt], ["Expiration Time", expirationTime], ["Not Before", notBefore]]) {
|
|
533
|
-
if (value !== void 0 && parseRfc3339(value) === null) bad(`${name} is not RFC 3339`);
|
|
534
|
-
}
|
|
535
|
-
return { domain: head[1], address, statement, uri, version, chainId, nonce, issuedAt, expirationTime, notBefore, resources };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// src/held-credentials/verify.ts
|
|
459
|
+
// src/held-credentials/credential.ts
|
|
539
460
|
var SHARED_SIGNED = /* @__PURE__ */ new Set(["iss", "sub", "iat", "nbf", "exp", "jti", "vct", "profile", "profileVersion", "holderBinding", "status", "_sd_alg", "_sd"]);
|
|
540
461
|
var RESERVED_CLAIMS = /* @__PURE__ */ new Set([...SHARED_SIGNED, "...", "cnf"]);
|
|
541
462
|
var HELD_LIMITS = {
|
|
@@ -551,36 +472,14 @@ var HELD_LIMITS = {
|
|
|
551
472
|
var HEADER_MEMBERS = /* @__PURE__ */ new Set(["alg", "typ", "kid"]);
|
|
552
473
|
var JTI_UUID_V4 = /^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
553
474
|
function sdHash(sdJwt) {
|
|
475
|
+
if (typeof sdJwt !== "string" || !isAscii(sdJwt)) refuse("bad-presentation", "sdJwt must be an ASCII string");
|
|
554
476
|
return encodeBase64Url(sha256(asciiBytes(sdJwt)));
|
|
555
477
|
}
|
|
556
478
|
function isSafeInt(value) {
|
|
557
479
|
return typeof value === "number" && Number.isSafeInteger(value);
|
|
558
480
|
}
|
|
559
|
-
|
|
560
|
-
const { now, issuerSet, acceptedIssuers, acceptedProfiles, didMethods,
|
|
561
|
-
const rows = options.rows ?? DEFAULT_ROWS;
|
|
562
|
-
if (!(now instanceof Date) || Number.isNaN(now.getTime())) refuse("bad-config", "now must be a valid Date");
|
|
563
|
-
if (!IssuerSet.is(issuerSet)) refuse("bad-config", "issuerSet must come from parseIssuerSet");
|
|
564
|
-
if (!nonceStore || !didMethods || !Array.isArray(didMethods.issuer)) refuse("bad-config", "issuerSet, nonceStore and didMethods are required");
|
|
565
|
-
if (!Array.isArray(acceptedIssuers) || acceptedIssuers.length === 0) refuse("bad-config", "acceptedIssuers must not be empty");
|
|
566
|
-
if (!Array.isArray(acceptedProfiles) || acceptedProfiles.length === 0) refuse("bad-config", "acceptedProfiles must not be empty");
|
|
567
|
-
assertValidAudience(audience);
|
|
568
|
-
if (parsePkhDid(callerAccount) === null) refuse("bad-config", "callerAccount must be a did:pkh:eip155 account");
|
|
569
|
-
if (!isObject(presentation) || Object.keys(presentation).sort().join() !== "sdJwt,siwe") refuse("bad-presentation", "unexpected shape");
|
|
570
|
-
const { sdJwt, siwe } = presentation;
|
|
571
|
-
if (typeof sdJwt !== "string" || !isAscii(sdJwt)) refuse("bad-presentation", "sdJwt must be an ASCII string");
|
|
572
|
-
if (!isObject(siwe) || Object.keys(siwe).sort().join() !== "message,signature" || typeof siwe.message !== "string" || typeof siwe.signature !== "string") refuse("bad-presentation", "siwe shape");
|
|
573
|
-
const sdJwtText = sdJwt;
|
|
574
|
-
const siweMessage = siwe.message;
|
|
575
|
-
const siweSignature = siwe.signature;
|
|
576
|
-
if (sdJwtText.length > HELD_LIMITS.sdJwtChars) refuse("bad-presentation", "sdJwt is too long");
|
|
577
|
-
if (siweMessage.length > HELD_LIMITS.siweMessageChars) refuse("bad-presentation", "siwe message is too long");
|
|
578
|
-
if (siweSignature.length > HELD_LIMITS.siweSignatureChars) refuse("bad-presentation", "siwe signature is too long");
|
|
579
|
-
const message = parseStrictSiwe(siweMessage);
|
|
580
|
-
if (message.domain !== audience) refuse("bad-siwe", "domain is not this verifier's audience");
|
|
581
|
-
if (!/^[0-9A-Za-z]{22,}$/.test(message.nonce)) refuse("bad-nonce", "nonce must be at least 22 base62 characters (or 32 hex)");
|
|
582
|
-
const consumed = await nonceStore.consume(audience, message.nonce, callerAccount);
|
|
583
|
-
if (!consumed.existed) refuse("nonce-replayed", "nonce is unknown, already used, or bound to another account");
|
|
481
|
+
function checkHeldCredential(sdJwtText, policy) {
|
|
482
|
+
const { now, issuerSet, acceptedIssuers, acceptedProfiles, didMethods, rows } = policy;
|
|
584
483
|
const parts = sdJwtText.split("~");
|
|
585
484
|
if (parts.length < 2 || parts[parts.length - 1] !== "") refuse("bad-presentation", "sdJwt must end with ~ and carry no key-binding JWT");
|
|
586
485
|
const [jwt, ...disclosureParts] = parts.slice(0, -1);
|
|
@@ -667,6 +566,183 @@ async function verifyHeldPresentation(presentation, options) {
|
|
|
667
566
|
claims[name] = value;
|
|
668
567
|
}
|
|
669
568
|
row.validateDisclosed(claims);
|
|
569
|
+
return {
|
|
570
|
+
issuer: issuerDid,
|
|
571
|
+
subject,
|
|
572
|
+
subjectPkh,
|
|
573
|
+
issuedAt: new Date(iat * 1e3),
|
|
574
|
+
expiresAt: new Date(exp * 1e3),
|
|
575
|
+
credentialId: payload.jti,
|
|
576
|
+
profile,
|
|
577
|
+
profileVersion,
|
|
578
|
+
vct,
|
|
579
|
+
claims
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function toVerifiedCredential(checked) {
|
|
583
|
+
return {
|
|
584
|
+
issuer: checked.issuer,
|
|
585
|
+
subject: canonicalPkhDid(checked.subject),
|
|
586
|
+
subjectAddress: registryKey(checked.subject),
|
|
587
|
+
expiresAt: checked.expiresAt,
|
|
588
|
+
credentialId: checked.credentialId,
|
|
589
|
+
profile: checked.profile,
|
|
590
|
+
profileVersion: checked.profileVersion,
|
|
591
|
+
vct: checked.vct,
|
|
592
|
+
claims: checked.claims
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function verifyHeldEmailCredential(sdJwt, options) {
|
|
596
|
+
const { now, issuerSet, acceptedIssuers, didMethods, owner } = options;
|
|
597
|
+
if (!(now instanceof Date) || Number.isNaN(now.getTime())) refuse("bad-config", "now must be a valid Date");
|
|
598
|
+
if (!IssuerSet.is(issuerSet)) refuse("bad-config", "issuerSet must come from parseIssuerSet");
|
|
599
|
+
if (!didMethods || !Array.isArray(didMethods.issuer)) refuse("bad-config", "didMethods is required");
|
|
600
|
+
if (!Array.isArray(acceptedIssuers) || acceptedIssuers.length === 0) refuse("bad-config", "acceptedIssuers must not be empty");
|
|
601
|
+
if (parsePkhDid(owner) === null) refuse("bad-config", "owner must be a did:pkh:eip155 account");
|
|
602
|
+
if (typeof sdJwt !== "string" || !isAscii(sdJwt)) refuse("bad-presentation", "sdJwt must be an ASCII string");
|
|
603
|
+
if (sdJwt.length > HELD_LIMITS.sdJwtChars) refuse("bad-presentation", "sdJwt is too long");
|
|
604
|
+
const checked = checkHeldCredential(sdJwt, {
|
|
605
|
+
now,
|
|
606
|
+
issuerSet,
|
|
607
|
+
acceptedIssuers,
|
|
608
|
+
didMethods,
|
|
609
|
+
acceptedProfiles: [{ profile: EMAIL_HELD_ROW.profile, profileVersion: EMAIL_HELD_ROW.profileVersion }],
|
|
610
|
+
rows: [EMAIL_HELD_ROW]
|
|
611
|
+
});
|
|
612
|
+
if (!pkhPrincipalsEqual(owner, checked.subject)) refuse("nonce-account-mismatch", "subject is not the authenticated owner");
|
|
613
|
+
return { ...toVerifiedCredential(checked), issuedAt: checked.issuedAt };
|
|
614
|
+
}
|
|
615
|
+
function selectDisclosures(sdJwt, names) {
|
|
616
|
+
if (typeof sdJwt !== "string") refuse("bad-presentation", "sdJwt must be a string");
|
|
617
|
+
if (names.length === 0 || new Set(names).size !== names.length) refuse("bad-config", "names must be non-empty and unique");
|
|
618
|
+
const parts = sdJwt.split("~");
|
|
619
|
+
if (parts.length < 2 || parts[parts.length - 1] !== "") refuse("bad-presentation", "sdJwt must end with ~ and carry no key-binding JWT");
|
|
620
|
+
const [jwt, ...disclosures] = parts.slice(0, -1);
|
|
621
|
+
const kept = [];
|
|
622
|
+
const found = /* @__PURE__ */ new Set();
|
|
623
|
+
for (const disclosure of disclosures) {
|
|
624
|
+
const decoded = parseStrictJson(decodeUtf8(decodeBase64Url(disclosure, "bad-disclosure"), "bad-disclosure"), "bad-disclosure");
|
|
625
|
+
if (!Array.isArray(decoded) || decoded.length !== 3 || typeof decoded[1] !== "string") refuse("bad-disclosure", "disclosure must be [salt, name, value]");
|
|
626
|
+
const name = decoded[1];
|
|
627
|
+
if (names.includes(name)) {
|
|
628
|
+
if (found.has(name)) refuse("bad-disclosure", `duplicate disclosure for ${name}`);
|
|
629
|
+
kept.push(disclosure);
|
|
630
|
+
found.add(name);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const name of names) if (!found.has(name)) refuse("bad-disclosure", `no disclosure for ${name}`);
|
|
634
|
+
return [jwt, ...kept, ""].join("~");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/held-credentials/siwe.ts
|
|
638
|
+
var PRESENTATION_STATEMENT = "Present a TinyCloud held credential";
|
|
639
|
+
var SD_HASH_RESOURCE_PREFIX = "urn:tinycloud:sd-hash:";
|
|
640
|
+
var RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(?:Z|([+-])(\d{2}):(\d{2}))$/;
|
|
641
|
+
function parseRfc3339(value) {
|
|
642
|
+
const m = RFC3339.exec(value);
|
|
643
|
+
if (!m) return null;
|
|
644
|
+
const [year, month, day, hour, minute, second] = [m[1], m[2], m[3], m[4], m[5], m[6]].map(Number);
|
|
645
|
+
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) return null;
|
|
646
|
+
const date = /* @__PURE__ */ new Date(0);
|
|
647
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
648
|
+
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
|
|
649
|
+
let offsetMinutes = 0;
|
|
650
|
+
if (m[8] !== void 0) {
|
|
651
|
+
const oh = Number(m[9]), om = Number(m[10]);
|
|
652
|
+
if (oh > 23 || om > 59) return null;
|
|
653
|
+
offsetMinutes = (m[8] === "-" ? -1 : 1) * (oh * 60 + om);
|
|
654
|
+
}
|
|
655
|
+
const millis = m[7] === void 0 ? 0 : Number(m[7].padEnd(3, "0").slice(0, 3));
|
|
656
|
+
date.setUTCHours(hour, minute, second, millis);
|
|
657
|
+
return date.getTime() - offsetMinutes * 6e4;
|
|
658
|
+
}
|
|
659
|
+
var AUDIENCE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::([1-9][0-9]{0,4}))?$/;
|
|
660
|
+
function assertValidAudience(audience) {
|
|
661
|
+
const match = AUDIENCE.exec(audience);
|
|
662
|
+
if (!match || match[1] !== void 0 && (Number(match[1]) > 65535 || match[1] === "443")) {
|
|
663
|
+
refuse("bad-config", "audience must be a lowercase host[:port] authority without a default port");
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
function parseStrictSiwe(message) {
|
|
667
|
+
const bad = (why) => refuse("bad-siwe", why);
|
|
668
|
+
const lines = message.split("\n");
|
|
669
|
+
const head = /^(\S+) wants you to sign in with your Ethereum account:$/.exec(lines[0] ?? "");
|
|
670
|
+
if (!head) bad("header line");
|
|
671
|
+
const address = lines[1] ?? "";
|
|
672
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(address)) bad("address");
|
|
673
|
+
if (toChecksumAddress(address) !== address) bad("address must be EIP-55 checksummed");
|
|
674
|
+
if (lines[2] !== "") bad("layout");
|
|
675
|
+
const statement = lines[3];
|
|
676
|
+
if (statement === void 0 || statement === "") bad("statement");
|
|
677
|
+
if (lines[4] !== "") bad("layout");
|
|
678
|
+
let index = 5;
|
|
679
|
+
const field = (name, optional = false) => {
|
|
680
|
+
const line = lines[index];
|
|
681
|
+
const prefix = `${name}: `;
|
|
682
|
+
if (line === void 0 || !line.startsWith(prefix)) {
|
|
683
|
+
if (optional) return void 0;
|
|
684
|
+
return bad(`missing ${name}`);
|
|
685
|
+
}
|
|
686
|
+
index += 1;
|
|
687
|
+
const value = line.slice(prefix.length);
|
|
688
|
+
if (value === "") bad(`empty ${name}`);
|
|
689
|
+
return value;
|
|
690
|
+
};
|
|
691
|
+
const uri = field("URI");
|
|
692
|
+
const version = field("Version");
|
|
693
|
+
const chainId = field("Chain ID");
|
|
694
|
+
const nonce = field("Nonce");
|
|
695
|
+
const issuedAt = field("Issued At");
|
|
696
|
+
const expirationTime = field("Expiration Time", true);
|
|
697
|
+
const notBefore = field("Not Before", true);
|
|
698
|
+
if (lines[index]?.startsWith("Request ID: ")) bad("Request ID is not accepted");
|
|
699
|
+
const resources = [];
|
|
700
|
+
if (lines[index] === "Resources:") {
|
|
701
|
+
index += 1;
|
|
702
|
+
while (lines[index] !== void 0 && lines[index].startsWith("- ")) {
|
|
703
|
+
resources.push(lines[index].slice(2));
|
|
704
|
+
index += 1;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (index !== lines.length) bad("trailing content");
|
|
708
|
+
if (!/^[0-9]+$/.test(chainId)) bad("chain id");
|
|
709
|
+
if (!/^[A-Za-z0-9]{8,}$/.test(nonce)) bad("nonce must be alphanumeric");
|
|
710
|
+
for (const [name, value] of [["Issued At", issuedAt], ["Expiration Time", expirationTime], ["Not Before", notBefore]]) {
|
|
711
|
+
if (value !== void 0 && parseRfc3339(value) === null) bad(`${name} is not RFC 3339`);
|
|
712
|
+
}
|
|
713
|
+
return { domain: head[1], address, statement, uri, version, chainId, nonce, issuedAt, expirationTime, notBefore, resources };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/held-credentials/verify.ts
|
|
717
|
+
async function verifyHeldPresentation(presentation, options) {
|
|
718
|
+
const { now, issuerSet, acceptedIssuers, acceptedProfiles, didMethods, audience, nonceStore, callerAccount } = options;
|
|
719
|
+
const rows = options.rows ?? DEFAULT_ROWS;
|
|
720
|
+
if (!(now instanceof Date) || Number.isNaN(now.getTime())) refuse("bad-config", "now must be a valid Date");
|
|
721
|
+
if (!IssuerSet.is(issuerSet)) refuse("bad-config", "issuerSet must come from parseIssuerSet");
|
|
722
|
+
if (!nonceStore || !didMethods || !Array.isArray(didMethods.issuer)) refuse("bad-config", "issuerSet, nonceStore and didMethods are required");
|
|
723
|
+
if (!Array.isArray(acceptedIssuers) || acceptedIssuers.length === 0) refuse("bad-config", "acceptedIssuers must not be empty");
|
|
724
|
+
if (!Array.isArray(acceptedProfiles) || acceptedProfiles.length === 0) refuse("bad-config", "acceptedProfiles must not be empty");
|
|
725
|
+
assertValidAudience(audience);
|
|
726
|
+
if (parsePkhDid(callerAccount) === null) refuse("bad-config", "callerAccount must be a did:pkh:eip155 account");
|
|
727
|
+
if (!isObject(presentation) || Object.keys(presentation).sort().join() !== "sdJwt,siwe") refuse("bad-presentation", "unexpected shape");
|
|
728
|
+
const { sdJwt, siwe } = presentation;
|
|
729
|
+
if (typeof sdJwt !== "string" || !isAscii(sdJwt)) refuse("bad-presentation", "sdJwt must be an ASCII string");
|
|
730
|
+
if (!isObject(siwe) || Object.keys(siwe).sort().join() !== "message,signature" || typeof siwe.message !== "string" || typeof siwe.signature !== "string") refuse("bad-presentation", "siwe shape");
|
|
731
|
+
const sdJwtText = sdJwt;
|
|
732
|
+
const siweMessage = siwe.message;
|
|
733
|
+
const siweSignature = siwe.signature;
|
|
734
|
+
if (sdJwtText.length > HELD_LIMITS.sdJwtChars) refuse("bad-presentation", "sdJwt is too long");
|
|
735
|
+
if (siweMessage.length > HELD_LIMITS.siweMessageChars) refuse("bad-presentation", "siwe message is too long");
|
|
736
|
+
if (siweSignature.length > HELD_LIMITS.siweSignatureChars) refuse("bad-presentation", "siwe signature is too long");
|
|
737
|
+
const message = parseStrictSiwe(siweMessage);
|
|
738
|
+
if (message.domain !== audience) refuse("bad-siwe", "domain is not this verifier's audience");
|
|
739
|
+
if (!/^[0-9A-Za-z]{22,}$/.test(message.nonce)) refuse("bad-nonce", "nonce must be at least 22 base62 characters (or 32 hex)");
|
|
740
|
+
const consumed = await nonceStore.consume(audience, message.nonce, callerAccount);
|
|
741
|
+
if (!consumed.existed) refuse("nonce-replayed", "nonce is unknown, already used, or bound to another account");
|
|
742
|
+
const checked = checkHeldCredential(sdJwtText, { now, issuerSet, acceptedIssuers, acceptedProfiles, didMethods, rows });
|
|
743
|
+
const subject = checked.subject;
|
|
744
|
+
const subjectPkh = checked.subjectPkh;
|
|
745
|
+
const nowMs = now.getTime();
|
|
670
746
|
let origin;
|
|
671
747
|
try {
|
|
672
748
|
const url = new URL(message.uri);
|
|
@@ -686,17 +762,7 @@ async function verifyHeldPresentation(presentation, options) {
|
|
|
686
762
|
const recovered = recoverEip191Address(siweMessage, signatureBytes);
|
|
687
763
|
if (recovered === null || recovered !== subjectPkh.address) refuse("bad-siwe-signature", "signature does not recover the credential subject");
|
|
688
764
|
if (!pkhPrincipalsEqual(callerAccount, subject)) refuse("nonce-account-mismatch", "subject is not the authenticated caller");
|
|
689
|
-
return
|
|
690
|
-
issuer: issuerDid,
|
|
691
|
-
subject: canonicalPkhDid(subject),
|
|
692
|
-
subjectAddress: registryKey(subject),
|
|
693
|
-
expiresAt: new Date(exp * 1e3),
|
|
694
|
-
credentialId: payload.jti,
|
|
695
|
-
profile,
|
|
696
|
-
profileVersion,
|
|
697
|
-
vct,
|
|
698
|
-
claims
|
|
699
|
-
};
|
|
765
|
+
return toVerifiedCredential(checked);
|
|
700
766
|
}
|
|
701
767
|
|
|
702
768
|
// src/held-credentials/nonce-store.ts
|
|
@@ -744,6 +810,105 @@ function normaliseEmail(email) {
|
|
|
744
810
|
if (local === "") return null;
|
|
745
811
|
return `${local}@${domain}`;
|
|
746
812
|
}
|
|
813
|
+
|
|
814
|
+
// src/held-credentials/presentation.ts
|
|
815
|
+
import { secp256k1 as secp256k12 } from "@noble/curves/secp256k1";
|
|
816
|
+
var NONCE_FORMAT = /^[0-9A-Za-z]{22,}$/;
|
|
817
|
+
var SD_HASH_FORMAT = /^[A-Za-z0-9_-]{43}$/;
|
|
818
|
+
var SIGNATURE_FORMAT = /^0x[0-9a-fA-F]{130}$/;
|
|
819
|
+
var SECP256K1_HALF_ORDER = secp256k12.CURVE.n / 2n;
|
|
820
|
+
function isValidDate(value) {
|
|
821
|
+
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
822
|
+
}
|
|
823
|
+
function hasLowS(signature) {
|
|
824
|
+
return BigInt(`0x${signature.slice(66, 130)}`) <= SECP256K1_HALF_ORDER;
|
|
825
|
+
}
|
|
826
|
+
function wholeSecondRfc3339(value, field) {
|
|
827
|
+
if (value.getUTCMilliseconds() !== 0) refuse("bad-config", `${field} must be a whole second`);
|
|
828
|
+
return value.toISOString().replace(".000Z", "Z");
|
|
829
|
+
}
|
|
830
|
+
function rfc3339Millis(value) {
|
|
831
|
+
const millis = parseRfc3339(value);
|
|
832
|
+
if (millis === null) refuse("bad-siwe", "SIWE timestamp is not RFC 3339");
|
|
833
|
+
return millis;
|
|
834
|
+
}
|
|
835
|
+
async function buildHeldPresentationSiwe(input) {
|
|
836
|
+
const { audience, nonce, sdHash: hash, account, issuedAt, expirationTime, sign } = input;
|
|
837
|
+
if (typeof audience !== "string") refuse("bad-config", "audience must be a string");
|
|
838
|
+
assertValidAudience(audience);
|
|
839
|
+
const subject = parsePkhDid(account);
|
|
840
|
+
if (subject === null) refuse("bad-config", "account must be a did:pkh:eip155 account");
|
|
841
|
+
if (subject.chainId !== "1") refuse("bad-config", "held presentations are on chain 1");
|
|
842
|
+
if (typeof nonce !== "string" || !NONCE_FORMAT.test(nonce)) refuse("bad-config", "nonce must be at least 22 base62 characters");
|
|
843
|
+
if (typeof hash !== "string" || !SD_HASH_FORMAT.test(hash)) refuse("bad-config", "sdHash must be a base64url SHA-256");
|
|
844
|
+
if (!isValidDate(issuedAt)) refuse("bad-config", "issuedAt must be a valid Date");
|
|
845
|
+
if (expirationTime !== void 0 && !isValidDate(expirationTime)) refuse("bad-config", "expirationTime must be a valid Date");
|
|
846
|
+
if (expirationTime !== void 0 && expirationTime.getTime() <= issuedAt.getTime()) refuse("bad-config", "expirationTime must be after issuedAt");
|
|
847
|
+
if (typeof sign !== "function") refuse("bad-config", "sign is required");
|
|
848
|
+
const message = [
|
|
849
|
+
`${audience} wants you to sign in with your Ethereum account:`,
|
|
850
|
+
toChecksumAddress(subject.address),
|
|
851
|
+
"",
|
|
852
|
+
PRESENTATION_STATEMENT,
|
|
853
|
+
"",
|
|
854
|
+
`URI: https://${audience}`,
|
|
855
|
+
"Version: 1",
|
|
856
|
+
"Chain ID: 1",
|
|
857
|
+
`Nonce: ${nonce}`,
|
|
858
|
+
`Issued At: ${wholeSecondRfc3339(issuedAt, "issuedAt")}`,
|
|
859
|
+
...expirationTime === void 0 ? [] : [`Expiration Time: ${wholeSecondRfc3339(expirationTime, "expirationTime")}`],
|
|
860
|
+
"Resources:",
|
|
861
|
+
`- ${SD_HASH_RESOURCE_PREFIX}${hash}`
|
|
862
|
+
].join("\n");
|
|
863
|
+
parseStrictSiwe(message);
|
|
864
|
+
if (message.length > HELD_LIMITS.siweMessageChars) refuse("bad-config", "SIWE message exceeds the node limit");
|
|
865
|
+
const signature = await sign(message);
|
|
866
|
+
if (typeof signature !== "string" || !SIGNATURE_FORMAT.test(signature)) refuse("bad-config", "signer must return 0x-prefixed 65-byte hex");
|
|
867
|
+
if (!hasLowS(signature)) refuse("bad-siwe-signature", "signer must return a low-S signature");
|
|
868
|
+
const recovered = recoverEip191Address(message, hexToBytes(signature.slice(2), "bad-siwe-signature"));
|
|
869
|
+
if (recovered !== subject.address) refuse("bad-siwe-signature", "signer does not control the account");
|
|
870
|
+
return { message, signature };
|
|
871
|
+
}
|
|
872
|
+
function checkReturnedPresentation(presentation, options) {
|
|
873
|
+
const { now, audience, account } = options;
|
|
874
|
+
if (!isValidDate(now)) refuse("bad-config", "now must be a valid Date");
|
|
875
|
+
assertValidAudience(audience);
|
|
876
|
+
if (parsePkhDid(account) === null) refuse("bad-config", "account must be a did:pkh:eip155 account");
|
|
877
|
+
if (!isObject(presentation) || Object.keys(presentation).sort().join() !== "sdJwt,siwe") refuse("bad-presentation", "unexpected shape");
|
|
878
|
+
const { sdJwt, siwe } = presentation;
|
|
879
|
+
if (typeof sdJwt !== "string" || !isAscii(sdJwt) || sdJwt.length > HELD_LIMITS.sdJwtChars) refuse("bad-presentation", "sdJwt must be a bounded ASCII string");
|
|
880
|
+
if (!isObject(siwe) || Object.keys(siwe).sort().join() !== "message,signature" || typeof siwe.message !== "string" || typeof siwe.signature !== "string") {
|
|
881
|
+
refuse("bad-presentation", "siwe must carry message and signature");
|
|
882
|
+
}
|
|
883
|
+
const siweMessage = siwe.message;
|
|
884
|
+
const siweSignature = siwe.signature;
|
|
885
|
+
if (siweMessage.length > HELD_LIMITS.siweMessageChars) refuse("bad-presentation", "siwe message is too long");
|
|
886
|
+
if (!SIGNATURE_FORMAT.test(siweSignature) || !hasLowS(siweSignature)) refuse("bad-siwe-signature", "signature must be 0x-prefixed 65-byte low-S hex");
|
|
887
|
+
const parsed = parseStrictSiwe(siweMessage);
|
|
888
|
+
if (parsed.domain !== audience) refuse("bad-siwe", "domain is not the audience");
|
|
889
|
+
let origin;
|
|
890
|
+
try {
|
|
891
|
+
const url = new URL(parsed.uri);
|
|
892
|
+
origin = url.protocol === "https:" && !url.username && !url.password ? url.origin : void 0;
|
|
893
|
+
} catch {
|
|
894
|
+
origin = void 0;
|
|
895
|
+
}
|
|
896
|
+
if (origin !== `https://${audience}`) refuse("bad-siwe", "uri must be an https origin matching the audience");
|
|
897
|
+
if (parsed.version !== "1") refuse("bad-siwe", "version must be 1");
|
|
898
|
+
if (parsed.statement !== PRESENTATION_STATEMENT) refuse("bad-siwe", "unexpected statement");
|
|
899
|
+
if (parsed.chainId !== "1") refuse("bad-siwe", "chain id must be 1");
|
|
900
|
+
if (!NONCE_FORMAT.test(parsed.nonce)) refuse("bad-nonce", "nonce must be at least 22 base62 characters");
|
|
901
|
+
if (parsed.resources.length !== 1 || parsed.resources[0] !== `${SD_HASH_RESOURCE_PREFIX}${sdHash(sdJwt)}`) {
|
|
902
|
+
refuse("bad-siwe", "resources must be exactly the sd_hash of this presentation");
|
|
903
|
+
}
|
|
904
|
+
const nowMs = now.getTime();
|
|
905
|
+
if (parsed.expirationTime !== void 0 && nowMs >= rfc3339Millis(parsed.expirationTime)) refuse("bad-siwe", "SIWE has expired");
|
|
906
|
+
if (parsed.notBefore !== void 0 && nowMs < rfc3339Millis(parsed.notBefore)) refuse("bad-siwe", "SIWE is not yet valid");
|
|
907
|
+
if (!pkhPrincipalsEqual(`did:pkh:eip155:${parsed.chainId}:${parsed.address}`, account)) refuse("bad-siwe", "SIWE address is not the account");
|
|
908
|
+
const recovered = recoverEip191Address(siweMessage, hexToBytes(siweSignature.slice(2), "bad-siwe-signature"));
|
|
909
|
+
if (recovered === null || recovered !== parsed.address.toLowerCase()) refuse("bad-siwe-signature", "signature does not recover the SIWE address");
|
|
910
|
+
return { sdJwt, siwe: { message: siweMessage, signature: siweSignature } };
|
|
911
|
+
}
|
|
747
912
|
export {
|
|
748
913
|
ACCOUNT_BINDING_DOMAIN,
|
|
749
914
|
DEFAULT_ROWS,
|
|
@@ -759,13 +924,18 @@ export {
|
|
|
759
924
|
PRESENTATION_STATEMENT,
|
|
760
925
|
SD_HASH_RESOURCE_PREFIX,
|
|
761
926
|
assertValidAudience,
|
|
927
|
+
buildHeldPresentationSiwe,
|
|
928
|
+
checkReturnedPresentation,
|
|
762
929
|
normaliseEmail,
|
|
763
930
|
parseIssuerSet,
|
|
764
931
|
parsePkhDid,
|
|
765
932
|
pkhPrincipalsEqual,
|
|
766
933
|
randomNonce,
|
|
767
934
|
registryKey,
|
|
935
|
+
sdHash,
|
|
936
|
+
selectDisclosures,
|
|
768
937
|
toChecksumAddress,
|
|
938
|
+
verifyHeldEmailCredential,
|
|
769
939
|
verifyHeldPresentation
|
|
770
940
|
};
|
|
771
941
|
//# sourceMappingURL=index.js.map
|