@coordination-network/user-app-link 0.0.2 → 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 +73 -4
- package/dist/index.cjs +100 -3
- package/dist/index.js +98 -4
- package/dist/web.cjs +30 -2
- package/dist/web.js +30 -3
- package/package.json +1 -1
- package/src/handler.ts +8 -3
- package/src/index.ts +2 -0
- package/src/messages.ts +18 -0
- package/src/presentation-qr.ts +79 -0
- package/src/types.ts +4 -0
- package/src/web.ts +32 -2
- package/types/index.d.ts +29 -3
- package/types/web.d.ts +5 -1
package/README.md
CHANGED
|
@@ -26,7 +26,9 @@ yarn add @coordination-network/user-app-link
|
|
|
26
26
|
{ id, version: "v1", ok: true, result } | { id, version: "v1", ok: false, code, message? }
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`code` is one of `USER_DENIED`, `CONSENT_DENIED`, `UNSUPPORTED_OP`, `INVALID_REQUEST`, `NOT_ONBOARDED`, `NO_CREDENTIAL`, `CREDENTIAL_REVOKED`, `PROOF_FAILED`, `ISSUER_UNREACHABLE`, `VERIFIER_UNREACHABLE`, `INTERNAL`.
|
|
29
|
+
`code` is one of `USER_DENIED`, `CONSENT_DENIED`, `UNSUPPORTED_OP`, `INVALID_REQUEST`, `NOT_ONBOARDED`, `NO_CREDENTIAL`, `CREDENTIAL_REVOKED`, `PROOF_FAILED`, `ISSUER_UNREACHABLE`, `VERIFIER_UNREACHABLE`, `ISSUER_NOT_APPROVED`, `CHAIN_UNREACHABLE`, `INTERNAL`.
|
|
30
|
+
|
|
31
|
+
`message` is one fixed sentence per code (`LINK_ERROR_MESSAGES`), so a response never carries detail about the holder. The one exception is `INVALID_REQUEST` for malformed params, which describes the caller's own request.
|
|
30
32
|
|
|
31
33
|
**Size.** `encodeRequest`/`encodeResponse` throw `LinkPayloadTooLargeError` if the built URL exceeds `MAX_URL_LENGTH` (2000 characters) — the point past which platform `Linking.openURL` calls and some OSes start truncating or refusing a URL. Every result below fits comfortably; the two payloads that wouldn't (a credential and a ZK proof) never travel over a link at all — the user app fetches and posts both itself.
|
|
32
34
|
|
|
@@ -44,9 +46,9 @@ Three ops, one per flow, each behind exactly one consent.
|
|
|
44
46
|
|
|
45
47
|
`securityLevel` is one of `STRONGBOX`, `TRUSTED_ENVIRONMENT`, `SOFTWARE`, `UNKNOWN`. `registration.sign`'s `request` is posted to the member server verbatim; the security level rides along because registration is the first thing every member app does and nothing else in the request says how the phone guards its keys.
|
|
46
48
|
|
|
47
|
-
`credential.issue` runs the whole issuance inside the user app after one approval: it checks what it already holds, gets a nonce from `challengeUrl`, builds the proof-of-possession material, posts it to `credentialsUrl`, checks the credential it gets back against `issuersUrl` and `revocationsUrl`, and stores it. The revocation secret never leaves the user app. `refused` is the issuer server's own decision, in its own error vocabulary; a server that cannot be reached at all is an `ISSUER_UNREACHABLE` error, not a refusal.
|
|
49
|
+
`credential.issue` runs the whole issuance inside the user app after one approval: it checks what it already holds, gets a nonce from `challengeUrl`, builds the proof-of-possession material, posts it to `credentialsUrl`, checks the credential it gets back against `issuersUrl` and `revocationsUrl` (lists that must hash to the chain's current roots, else `ISSUER_UNREACHABLE`), checks that its issuer key is one the chain lists for `issuerNetworkId` (`ISSUER_NOT_APPROVED`, or `CHAIN_UNREACHABLE` when the chain cannot be read), and stores it. The revocation secret never leaves the user app. `refused` is the issuer server's own decision, in its own error vocabulary; a server that cannot be reached at all is an `ISSUER_UNREACHABLE` error, not a refusal.
|
|
48
50
|
|
|
49
|
-
`presentation.create` does not return a proof. `requestId` identifies a presentation request the member server already issued via `POST /v1/presentation-requests` (which returns `{ requestId, challenge, audience, currentTime, programme, country }`); the rest of the params are that same response, since the user app still needs them to build the proof inputs. The user app picks a matching credential, checks it against
|
|
51
|
+
`presentation.create` does not return a proof. `requestId` identifies a presentation request the member server already issued via `POST /v1/presentation-requests` (which returns `{ requestId, challenge, audience, currentTime, programme, country }`); the rest of the params are that same response, since the user app still needs them to build the proof inputs. The user app picks a matching credential, checks it against the chain's current roots (the verifier's served lists must hash to them, or the request fails with `VERIFIER_UNREACHABLE`), proves it, asks the person once more before sharing, and itself `POST`s `{ requestId, proof, publicInputs }` to `responseUrl` (the member server's `/v1/verify`). The result is only the `verificationId`: the member app reads the outcome from its own server at `GET /v1/verifications/:id` rather than trusting a relayed answer. A proof is large — far past `MAX_URL_LENGTH` — so it never travels as a query parameter.
|
|
50
52
|
|
|
51
53
|
## Using `createLinkClient` (member app side)
|
|
52
54
|
|
|
@@ -70,9 +72,68 @@ console.log(signed.request.networkId)
|
|
|
70
72
|
|
|
71
73
|
`request` encodes the op, opens the request URL in the user app, and resolves with the typed result (or rejects with a `LinkError` carrying the response's `code`) once the matching callback URL arrives. One request may be in flight at a time per client; a second call while one is pending rejects immediately.
|
|
72
74
|
|
|
75
|
+
### WS1 mobile presentation transport (#112)
|
|
76
|
+
|
|
77
|
+
The holder's wallet and verifier's member app can run on **separate Android phones**. The verifier
|
|
78
|
+
shows a public challenge QR; the holder scans it, reviews the request and consents to proving. The
|
|
79
|
+
wallet posts the proof directly to the verifier server (HTTPS for hosted services). The verifier
|
|
80
|
+
polls its server with its own member token; no app callback needs to cross devices. Both phones
|
|
81
|
+
need connectivity, and the wallet also needs its configured chain RPC. This is an online reference
|
|
82
|
+
demo, not a claim of offline suitability for every field deployment. The same-phone deep-link
|
|
83
|
+
flow remains available. Both configurations must name a verifier URL reachable from each phone;
|
|
84
|
+
localhost URLs require USB reverse forwarding on each device.
|
|
85
|
+
|
|
86
|
+
1. In the member app, choose **Verify → Check another phone → Create challenge QR**. The server's
|
|
87
|
+
`POST /v1/presentation-requests` supplies the single-use challenge, audience, scope, time and
|
|
88
|
+
expiry. The screen renders `cn-present:v1:` with a compact encoding of these fields and one
|
|
89
|
+
server base URL. It carries no token, credential, proof or callback. URLs are reconstructed
|
|
90
|
+
from that base; encoded QR data is capped at 2,000 characters.
|
|
91
|
+
2. In the wallet, choose **Scan proof → Scan challenge QR**. Before network access or consent,
|
|
92
|
+
the wallet checks the schema and time bounds and pins the audience and every endpoint to a
|
|
93
|
+
member in its previously configured stack. Scanning a QR never adds a new trusted verifier.
|
|
94
|
+
The person reviews the configured verifier and programme/country, then approves sharing.
|
|
95
|
+
3. The wallet reads the verifier's lists and checks them against roots from its own chain RPC,
|
|
96
|
+
builds the proof, and posts `{ requestId, proof, publicInputs }` to `/v1/verify`. A proof
|
|
97
|
+
discloses time, challenge, audience, programme hash, country code and registry roots. The raw
|
|
98
|
+
credential, issuer identity and private keys are not sent. The wallet reports **submitted**,
|
|
99
|
+
not **verified**: only the verifier server's result establishes acceptance.
|
|
100
|
+
4. The verifier uses authenticated `GET /v1/presentation-requests/:id` to obtain `pending`,
|
|
101
|
+
`expired`, `cancelled`, or `completed` with its stored verification record. This is how a lost
|
|
102
|
+
wallet HTTP response can be recovered without replaying the proof. Only the original attempt
|
|
103
|
+
can set this result; replay failures cannot replace a successful verdict. Polling stops on a
|
|
104
|
+
terminal result, connection error, cancellation or screen exit. **Retry status** queries the
|
|
105
|
+
same request after a connection error. **New check** creates a fresh challenge.
|
|
106
|
+
5. **Cancel check** or leaving the verifier screen stops waiting and calls authenticated
|
|
107
|
+
`POST /v1/presentation-requests/:id/cancel`. Cancellation can invalidate an unsubmitted
|
|
108
|
+
request; once proof submission has claimed it, the server returns 409 and may still record
|
|
109
|
+
its result. The UI does not claim that an in-flight proof was cancelled.
|
|
110
|
+
|
|
111
|
+
**Payload and limits.** The proof travels as hex in JSON, not in the QR. The real-proof HTTP E2E
|
|
112
|
+
run measured 14,656 proof bytes, 29,886 JSON bytes and seven public inputs, within the server's
|
|
113
|
+
100 KiB parser limit. Circuit/backend changes must repeat that measurement. The member UI tests
|
|
114
|
+
rasterize the actual native QR Views and decode them with an independent QR decoder, checking
|
|
115
|
+
that the challenge fits and contains no token. The same-phone link tests also check its separate
|
|
116
|
+
2,000-character limit.
|
|
117
|
+
|
|
118
|
+
**Offline behaviour.** There is no cached-root/offline acceptance. A list/verifier outage produces
|
|
119
|
+
`VERIFIER_UNREACHABLE`; an RPC outage produces `CHAIN_UNREACHABLE` and no proof is submitted.
|
|
120
|
+
A lost proof-POST response is not acceptance or rejection: the verifier checks its own request
|
|
121
|
+
status. A consented no-credential/revoked-credential refusal sends no proof and closes the request
|
|
122
|
+
with that refusal. Denied consent sends nothing; the verifier can cancel or let the request expire. By default requests expire after 300 seconds. After expiry or cancellation, start a new
|
|
123
|
+
check rather than replaying the old proof. Hosted endpoints must use HTTPS; configured LAN HTTP
|
|
124
|
+
is retained for the local demo, where QR contents and network traffic are not confidential.
|
|
125
|
+
|
|
126
|
+
**Two-phone acceptance for #67.** Configure both phones against the same current Sepolia stack.
|
|
127
|
+
Issue a credential to the holder, display a challenge on the verifier, scan and consent on the
|
|
128
|
+
holder, and confirm that only the verifier shows the matching server verdict. Repeat with denied
|
|
129
|
+
consent, a revoked credential, a stale/unknown-member QR, duplicate scan frames, cancellation,
|
|
130
|
+
verifier/RPC outage, and loss of the wallet response after server acceptance. Never project member
|
|
131
|
+
pairing tokens or record credentials/proofs. Automated UI and local HTTP/Anvil tests cover these
|
|
132
|
+
boundaries, but do not replace physical-camera, secure-hardware and live-Sepolia acceptance.
|
|
133
|
+
|
|
73
134
|
### Browser transport
|
|
74
135
|
|
|
75
|
-
Member apps choose a transport only when constructing the client; all
|
|
136
|
+
Member apps choose a transport only when constructing the client; all three operations and the rest of
|
|
76
137
|
their code stay the same when moving between web and mobile:
|
|
77
138
|
|
|
78
139
|
```typescript
|
|
@@ -135,6 +196,14 @@ await Linking.openURL(responseUrl)
|
|
|
135
196
|
- **No cross-device support.** Both apps are assumed to be installed on the same phone, communicating through the OS's own URL-scheme routing. There is no pairing, QR handoff, or relay for a user app and member app on different devices.
|
|
136
197
|
- **No responder authentication.** See "Security notes" above: this protocol alone doesn't prove `callback` belongs to the app the user thinks it does.
|
|
137
198
|
|
|
199
|
+
### Local demo relay
|
|
200
|
+
|
|
201
|
+
`createRelayWebLinkTransport` requires a private `token` for the running demo session. It sends it
|
|
202
|
+
in the Authorization header for requests, response polls and delivery acknowledgements. The relay
|
|
203
|
+
URL and the public stack descriptor must never carry that token. `browserSessionKey` reads an
|
|
204
|
+
operator pairing fragment or prompt, removes the fragment key and stores it only for that tab and
|
|
205
|
+
service. See the [relay setup](../../apps/integration-testbed/README.md#browser-link-relay-e1--l7).
|
|
206
|
+
|
|
138
207
|
## License
|
|
139
208
|
|
|
140
209
|
MIT, see [`LICENSE`](./LICENSE).
|
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,10 @@ const LINK_ERROR_CODES = [
|
|
|
23
23
|
"PROOF_FAILED",
|
|
24
24
|
"ISSUER_UNREACHABLE",
|
|
25
25
|
"VERIFIER_UNREACHABLE",
|
|
26
|
+
/** The credential's issuer key is not among those the chain lists for the member the caller named. */
|
|
27
|
+
"ISSUER_NOT_APPROVED",
|
|
28
|
+
/** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
|
|
29
|
+
"CHAIN_UNREACHABLE",
|
|
26
30
|
"INTERNAL"
|
|
27
31
|
];
|
|
28
32
|
|
|
@@ -482,6 +486,23 @@ function createLinkClient(options) {
|
|
|
482
486
|
};
|
|
483
487
|
}
|
|
484
488
|
|
|
489
|
+
/** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
|
|
490
|
+
const LINK_ERROR_MESSAGES = {
|
|
491
|
+
USER_DENIED: "The person declined the request.",
|
|
492
|
+
CONSENT_DENIED: "The person declined to share.",
|
|
493
|
+
UNSUPPORTED_OP: "This wallet does not support that operation.",
|
|
494
|
+
INVALID_REQUEST: "The request was not valid.",
|
|
495
|
+
NOT_ONBOARDED: "The wallet has no identity yet.",
|
|
496
|
+
NO_CREDENTIAL: "No matching credential.",
|
|
497
|
+
CREDENTIAL_REVOKED: "The matching credential has been revoked.",
|
|
498
|
+
PROOF_FAILED: "The wallet could not build the proof.",
|
|
499
|
+
ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
|
|
500
|
+
VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
|
|
501
|
+
ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
|
|
502
|
+
CHAIN_UNREACHABLE: "The wallet could not read the chain.",
|
|
503
|
+
INTERNAL: "The wallet failed."
|
|
504
|
+
};
|
|
505
|
+
|
|
485
506
|
function isLinkOp(op) {
|
|
486
507
|
return LINK_OPS.includes(op);
|
|
487
508
|
}
|
|
@@ -489,8 +510,8 @@ function isLinkOp(op) {
|
|
|
489
510
|
const MAX_RESPONSE_MESSAGE_LENGTH = 256;
|
|
490
511
|
function createRequestHandler(options) {
|
|
491
512
|
const { handlers, consent } = options;
|
|
492
|
-
function respond(request, code, message) {
|
|
493
|
-
const cappedMessage = message
|
|
513
|
+
function respond(request, code, message = LINK_ERROR_MESSAGES[code]) {
|
|
514
|
+
const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
|
|
494
515
|
return encodeResponse(request.callback, {
|
|
495
516
|
id: request.id,
|
|
496
517
|
version: "v1",
|
|
@@ -530,7 +551,7 @@ function createRequestHandler(options) {
|
|
|
530
551
|
}
|
|
531
552
|
catch (error) {
|
|
532
553
|
if (error instanceof LinkError) {
|
|
533
|
-
return respond(request, error.code
|
|
554
|
+
return respond(request, error.code);
|
|
534
555
|
}
|
|
535
556
|
return respond(request, "INTERNAL");
|
|
536
557
|
}
|
|
@@ -538,9 +559,83 @@ function createRequestHandler(options) {
|
|
|
538
559
|
};
|
|
539
560
|
}
|
|
540
561
|
|
|
562
|
+
const PREFIX = "cn-present:v1:";
|
|
563
|
+
const publicBase = zod.z
|
|
564
|
+
.string()
|
|
565
|
+
.regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment");
|
|
566
|
+
const schema = zod.z
|
|
567
|
+
.object({
|
|
568
|
+
params: presentationCreateParamsSchema,
|
|
569
|
+
expiresAt: zod.z.number().int().positive()
|
|
570
|
+
})
|
|
571
|
+
.strict();
|
|
572
|
+
/** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
|
|
573
|
+
function encodePresentationQr(value) {
|
|
574
|
+
const parsed = schema.parse(value);
|
|
575
|
+
const { params, expiresAt } = parsed;
|
|
576
|
+
const base = params.responseUrl.replace(/\/v1\/verify$/, "");
|
|
577
|
+
if (base === params.responseUrl ||
|
|
578
|
+
params.issuersUrl !== `${base}/v1/issuers` ||
|
|
579
|
+
params.revocationsUrl !== `${base}/v1/revocations/snapshot`)
|
|
580
|
+
throw new Error("Unsupported presentation endpoints");
|
|
581
|
+
publicBase.parse(base);
|
|
582
|
+
// Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
|
|
583
|
+
const wire = {
|
|
584
|
+
r: params.requestId,
|
|
585
|
+
c: params.challenge,
|
|
586
|
+
a: params.audience,
|
|
587
|
+
t: params.currentTime,
|
|
588
|
+
p: params.programme,
|
|
589
|
+
k: params.country,
|
|
590
|
+
u: base,
|
|
591
|
+
e: expiresAt
|
|
592
|
+
};
|
|
593
|
+
const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)));
|
|
594
|
+
if (encoded.length > MAX_URL_LENGTH)
|
|
595
|
+
throw new Error("Presentation QR is too large");
|
|
596
|
+
return encoded;
|
|
597
|
+
}
|
|
598
|
+
function decodePresentationQr(raw) {
|
|
599
|
+
if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH)
|
|
600
|
+
throw new Error("Not a presentation QR");
|
|
601
|
+
try {
|
|
602
|
+
const wire = zod.z
|
|
603
|
+
.object({
|
|
604
|
+
r: zod.z.string(),
|
|
605
|
+
c: zod.z.string(),
|
|
606
|
+
a: zod.z.string(),
|
|
607
|
+
t: zod.z.number(),
|
|
608
|
+
p: zod.z.string(),
|
|
609
|
+
k: zod.z.string(),
|
|
610
|
+
u: publicBase,
|
|
611
|
+
e: zod.z.number()
|
|
612
|
+
})
|
|
613
|
+
.strict()
|
|
614
|
+
.parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))));
|
|
615
|
+
return schema.parse({
|
|
616
|
+
expiresAt: wire.e,
|
|
617
|
+
params: {
|
|
618
|
+
requestId: wire.r,
|
|
619
|
+
challenge: wire.c,
|
|
620
|
+
audience: wire.a,
|
|
621
|
+
currentTime: wire.t,
|
|
622
|
+
programme: wire.p,
|
|
623
|
+
country: wire.k,
|
|
624
|
+
issuersUrl: `${wire.u}/v1/issuers`,
|
|
625
|
+
revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
|
|
626
|
+
responseUrl: `${wire.u}/v1/verify`
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
throw new Error("Invalid presentation QR");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
541
635
|
exports.ALLOWED_ENDPOINT_URL_SCHEMES = ALLOWED_ENDPOINT_URL_SCHEMES;
|
|
542
636
|
exports.DENIED_CALLBACK_SCHEMES = DENIED_CALLBACK_SCHEMES;
|
|
543
637
|
exports.LINK_ERROR_CODES = LINK_ERROR_CODES;
|
|
638
|
+
exports.LINK_ERROR_MESSAGES = LINK_ERROR_MESSAGES;
|
|
544
639
|
exports.LINK_OPS = LINK_OPS;
|
|
545
640
|
exports.LinkDecodeError = LinkDecodeError;
|
|
546
641
|
exports.LinkError = LinkError;
|
|
@@ -557,8 +652,10 @@ exports.createLinkClient = createLinkClient;
|
|
|
557
652
|
exports.createRequestHandler = createRequestHandler;
|
|
558
653
|
exports.credentialIssueParamsSchema = credentialIssueParamsSchema;
|
|
559
654
|
exports.credentialIssueResultSchema = credentialIssueResultSchema;
|
|
655
|
+
exports.decodePresentationQr = decodePresentationQr;
|
|
560
656
|
exports.decodeRequest = decodeRequest;
|
|
561
657
|
exports.decodeResponse = decodeResponse;
|
|
658
|
+
exports.encodePresentationQr = encodePresentationQr;
|
|
562
659
|
exports.encodeRequest = encodeRequest;
|
|
563
660
|
exports.encodeResponse = encodeResponse;
|
|
564
661
|
exports.hex20Schema = hex20Schema;
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,10 @@ const LINK_ERROR_CODES = [
|
|
|
21
21
|
"PROOF_FAILED",
|
|
22
22
|
"ISSUER_UNREACHABLE",
|
|
23
23
|
"VERIFIER_UNREACHABLE",
|
|
24
|
+
/** The credential's issuer key is not among those the chain lists for the member the caller named. */
|
|
25
|
+
"ISSUER_NOT_APPROVED",
|
|
26
|
+
/** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
|
|
27
|
+
"CHAIN_UNREACHABLE",
|
|
24
28
|
"INTERNAL"
|
|
25
29
|
];
|
|
26
30
|
|
|
@@ -480,6 +484,23 @@ function createLinkClient(options) {
|
|
|
480
484
|
};
|
|
481
485
|
}
|
|
482
486
|
|
|
487
|
+
/** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
|
|
488
|
+
const LINK_ERROR_MESSAGES = {
|
|
489
|
+
USER_DENIED: "The person declined the request.",
|
|
490
|
+
CONSENT_DENIED: "The person declined to share.",
|
|
491
|
+
UNSUPPORTED_OP: "This wallet does not support that operation.",
|
|
492
|
+
INVALID_REQUEST: "The request was not valid.",
|
|
493
|
+
NOT_ONBOARDED: "The wallet has no identity yet.",
|
|
494
|
+
NO_CREDENTIAL: "No matching credential.",
|
|
495
|
+
CREDENTIAL_REVOKED: "The matching credential has been revoked.",
|
|
496
|
+
PROOF_FAILED: "The wallet could not build the proof.",
|
|
497
|
+
ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
|
|
498
|
+
VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
|
|
499
|
+
ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
|
|
500
|
+
CHAIN_UNREACHABLE: "The wallet could not read the chain.",
|
|
501
|
+
INTERNAL: "The wallet failed."
|
|
502
|
+
};
|
|
503
|
+
|
|
483
504
|
function isLinkOp(op) {
|
|
484
505
|
return LINK_OPS.includes(op);
|
|
485
506
|
}
|
|
@@ -487,8 +508,8 @@ function isLinkOp(op) {
|
|
|
487
508
|
const MAX_RESPONSE_MESSAGE_LENGTH = 256;
|
|
488
509
|
function createRequestHandler(options) {
|
|
489
510
|
const { handlers, consent } = options;
|
|
490
|
-
function respond(request, code, message) {
|
|
491
|
-
const cappedMessage = message
|
|
511
|
+
function respond(request, code, message = LINK_ERROR_MESSAGES[code]) {
|
|
512
|
+
const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH);
|
|
492
513
|
return encodeResponse(request.callback, {
|
|
493
514
|
id: request.id,
|
|
494
515
|
version: "v1",
|
|
@@ -528,7 +549,7 @@ function createRequestHandler(options) {
|
|
|
528
549
|
}
|
|
529
550
|
catch (error) {
|
|
530
551
|
if (error instanceof LinkError) {
|
|
531
|
-
return respond(request, error.code
|
|
552
|
+
return respond(request, error.code);
|
|
532
553
|
}
|
|
533
554
|
return respond(request, "INTERNAL");
|
|
534
555
|
}
|
|
@@ -536,4 +557,77 @@ function createRequestHandler(options) {
|
|
|
536
557
|
};
|
|
537
558
|
}
|
|
538
559
|
|
|
539
|
-
|
|
560
|
+
const PREFIX = "cn-present:v1:";
|
|
561
|
+
const publicBase = z
|
|
562
|
+
.string()
|
|
563
|
+
.regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment");
|
|
564
|
+
const schema = z
|
|
565
|
+
.object({
|
|
566
|
+
params: presentationCreateParamsSchema,
|
|
567
|
+
expiresAt: z.number().int().positive()
|
|
568
|
+
})
|
|
569
|
+
.strict();
|
|
570
|
+
/** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
|
|
571
|
+
function encodePresentationQr(value) {
|
|
572
|
+
const parsed = schema.parse(value);
|
|
573
|
+
const { params, expiresAt } = parsed;
|
|
574
|
+
const base = params.responseUrl.replace(/\/v1\/verify$/, "");
|
|
575
|
+
if (base === params.responseUrl ||
|
|
576
|
+
params.issuersUrl !== `${base}/v1/issuers` ||
|
|
577
|
+
params.revocationsUrl !== `${base}/v1/revocations/snapshot`)
|
|
578
|
+
throw new Error("Unsupported presentation endpoints");
|
|
579
|
+
publicBase.parse(base);
|
|
580
|
+
// Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
|
|
581
|
+
const wire = {
|
|
582
|
+
r: params.requestId,
|
|
583
|
+
c: params.challenge,
|
|
584
|
+
a: params.audience,
|
|
585
|
+
t: params.currentTime,
|
|
586
|
+
p: params.programme,
|
|
587
|
+
k: params.country,
|
|
588
|
+
u: base,
|
|
589
|
+
e: expiresAt
|
|
590
|
+
};
|
|
591
|
+
const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)));
|
|
592
|
+
if (encoded.length > MAX_URL_LENGTH)
|
|
593
|
+
throw new Error("Presentation QR is too large");
|
|
594
|
+
return encoded;
|
|
595
|
+
}
|
|
596
|
+
function decodePresentationQr(raw) {
|
|
597
|
+
if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH)
|
|
598
|
+
throw new Error("Not a presentation QR");
|
|
599
|
+
try {
|
|
600
|
+
const wire = z
|
|
601
|
+
.object({
|
|
602
|
+
r: z.string(),
|
|
603
|
+
c: z.string(),
|
|
604
|
+
a: z.string(),
|
|
605
|
+
t: z.number(),
|
|
606
|
+
p: z.string(),
|
|
607
|
+
k: z.string(),
|
|
608
|
+
u: publicBase,
|
|
609
|
+
e: z.number()
|
|
610
|
+
})
|
|
611
|
+
.strict()
|
|
612
|
+
.parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))));
|
|
613
|
+
return schema.parse({
|
|
614
|
+
expiresAt: wire.e,
|
|
615
|
+
params: {
|
|
616
|
+
requestId: wire.r,
|
|
617
|
+
challenge: wire.c,
|
|
618
|
+
audience: wire.a,
|
|
619
|
+
currentTime: wire.t,
|
|
620
|
+
programme: wire.p,
|
|
621
|
+
country: wire.k,
|
|
622
|
+
issuersUrl: `${wire.u}/v1/issuers`,
|
|
623
|
+
revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
|
|
624
|
+
responseUrl: `${wire.u}/v1/verify`
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
throw new Error("Invalid presentation QR");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export { ALLOWED_ENDPOINT_URL_SCHEMES, DENIED_CALLBACK_SCHEMES, LINK_ERROR_CODES, LINK_ERROR_MESSAGES, LINK_OPS, LinkDecodeError, LinkError, LinkPayloadTooLargeError, MAX_URL_LENGTH, SECURITY_LEVELS, USER_APP_SCHEME, approvalScopeSchema, base64UrlDecode, base64UrlEncode, callbackSchema, callerSchema, createLinkClient, createRequestHandler, credentialIssueParamsSchema, credentialIssueResultSchema, decodePresentationQr, decodeRequest, decodeResponse, encodePresentationQr, encodeRequest, encodeResponse, hex20Schema, hex31Schema, hex32Schema, hex65Schema, hex66Schema, linkErrorCodeSchema, linkRequestEnvelopeSchema, linkRequestSchema, linkResponseSchema, paramsSchemas, presentationCreateParamsSchema, presentationCreateResultSchema, registrationSignParamsSchema, registrationSignResultSchema, resultSchemas, securityLevelSchema, signedRegistrationRequestSchema };
|
package/dist/web.cjs
CHANGED
|
@@ -37,6 +37,10 @@ const LINK_ERROR_CODES = [
|
|
|
37
37
|
"PROOF_FAILED",
|
|
38
38
|
"ISSUER_UNREACHABLE",
|
|
39
39
|
"VERIFIER_UNREACHABLE",
|
|
40
|
+
/** The credential's issuer key is not among those the chain lists for the member the caller named. */
|
|
41
|
+
"ISSUER_NOT_APPROVED",
|
|
42
|
+
/** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
|
|
43
|
+
"CHAIN_UNREACHABLE",
|
|
40
44
|
"INTERNAL"
|
|
41
45
|
];
|
|
42
46
|
|
|
@@ -505,16 +509,36 @@ function createEmbeddedWebLinkTransport(options) {
|
|
|
505
509
|
}
|
|
506
510
|
};
|
|
507
511
|
}
|
|
512
|
+
/** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
|
|
513
|
+
function browserSessionKey(serviceUrl, host = window, fragmentKey = "relayKey") {
|
|
514
|
+
const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`;
|
|
515
|
+
const current = new URL(host.location.href);
|
|
516
|
+
const fragment = new URLSearchParams(current.hash.slice(1));
|
|
517
|
+
const paired = fragment.get(fragmentKey);
|
|
518
|
+
if (paired !== null) {
|
|
519
|
+
fragment.delete(fragmentKey);
|
|
520
|
+
current.hash = fragment.toString();
|
|
521
|
+
host.history.replaceState(null, "", current.toString());
|
|
522
|
+
}
|
|
523
|
+
const token = paired ??
|
|
524
|
+
host.sessionStorage.getItem(storageKey) ??
|
|
525
|
+
host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim();
|
|
526
|
+
if (!token || !/^[0-9a-f]{64}$/i.test(token))
|
|
527
|
+
throw new Error("A demo connection key is required");
|
|
528
|
+
host.sessionStorage.setItem(storageKey, token);
|
|
529
|
+
return token;
|
|
530
|
+
}
|
|
508
531
|
/** Carries unchanged link URLs through the local demo relay between independently opened pages. */
|
|
509
532
|
function createRelayWebLinkTransport(options) {
|
|
510
533
|
const fetcher = options.fetcher ?? fetch;
|
|
511
534
|
const relayUrl = new URL(options.relayUrl).origin;
|
|
535
|
+
const headers = { authorization: `Bearer ${options.token}` };
|
|
512
536
|
let after = 0;
|
|
513
537
|
return {
|
|
514
538
|
async openUrl(url) {
|
|
515
539
|
const response = await fetcher(`${relayUrl}/v1/requests`, {
|
|
516
540
|
method: "POST",
|
|
517
|
-
headers: { "content-type": "application/json" },
|
|
541
|
+
headers: { "content-type": "application/json", ...headers },
|
|
518
542
|
body: JSON.stringify({ sender: options.sender, url })
|
|
519
543
|
});
|
|
520
544
|
if (!response.ok)
|
|
@@ -528,7 +552,7 @@ function createRelayWebLinkTransport(options) {
|
|
|
528
552
|
return;
|
|
529
553
|
polling = true;
|
|
530
554
|
try {
|
|
531
|
-
const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}
|
|
555
|
+
const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { headers });
|
|
532
556
|
if (!response.ok)
|
|
533
557
|
return;
|
|
534
558
|
const messages = (await response.json());
|
|
@@ -536,6 +560,9 @@ function createRelayWebLinkTransport(options) {
|
|
|
536
560
|
after = Math.max(after, message.seq);
|
|
537
561
|
listener(message.url);
|
|
538
562
|
}
|
|
563
|
+
if (messages.length > 0) {
|
|
564
|
+
await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { method: "DELETE", headers });
|
|
565
|
+
}
|
|
539
566
|
}
|
|
540
567
|
catch {
|
|
541
568
|
// The next poll retries while the local demo stack starts or restarts.
|
|
@@ -554,6 +581,7 @@ function createRelayWebLinkTransport(options) {
|
|
|
554
581
|
};
|
|
555
582
|
}
|
|
556
583
|
|
|
584
|
+
exports.browserSessionKey = browserSessionKey;
|
|
557
585
|
exports.createEmbeddedWebLinkTransport = createEmbeddedWebLinkTransport;
|
|
558
586
|
exports.createRelayWebLinkTransport = createRelayWebLinkTransport;
|
|
559
587
|
exports.createWebLinkTransport = createWebLinkTransport;
|
package/dist/web.js
CHANGED
|
@@ -35,6 +35,10 @@ const LINK_ERROR_CODES = [
|
|
|
35
35
|
"PROOF_FAILED",
|
|
36
36
|
"ISSUER_UNREACHABLE",
|
|
37
37
|
"VERIFIER_UNREACHABLE",
|
|
38
|
+
/** The credential's issuer key is not among those the chain lists for the member the caller named. */
|
|
39
|
+
"ISSUER_NOT_APPROVED",
|
|
40
|
+
/** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
|
|
41
|
+
"CHAIN_UNREACHABLE",
|
|
38
42
|
"INTERNAL"
|
|
39
43
|
];
|
|
40
44
|
|
|
@@ -503,16 +507,36 @@ function createEmbeddedWebLinkTransport(options) {
|
|
|
503
507
|
}
|
|
504
508
|
};
|
|
505
509
|
}
|
|
510
|
+
/** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
|
|
511
|
+
function browserSessionKey(serviceUrl, host = window, fragmentKey = "relayKey") {
|
|
512
|
+
const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`;
|
|
513
|
+
const current = new URL(host.location.href);
|
|
514
|
+
const fragment = new URLSearchParams(current.hash.slice(1));
|
|
515
|
+
const paired = fragment.get(fragmentKey);
|
|
516
|
+
if (paired !== null) {
|
|
517
|
+
fragment.delete(fragmentKey);
|
|
518
|
+
current.hash = fragment.toString();
|
|
519
|
+
host.history.replaceState(null, "", current.toString());
|
|
520
|
+
}
|
|
521
|
+
const token = paired ??
|
|
522
|
+
host.sessionStorage.getItem(storageKey) ??
|
|
523
|
+
host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim();
|
|
524
|
+
if (!token || !/^[0-9a-f]{64}$/i.test(token))
|
|
525
|
+
throw new Error("A demo connection key is required");
|
|
526
|
+
host.sessionStorage.setItem(storageKey, token);
|
|
527
|
+
return token;
|
|
528
|
+
}
|
|
506
529
|
/** Carries unchanged link URLs through the local demo relay between independently opened pages. */
|
|
507
530
|
function createRelayWebLinkTransport(options) {
|
|
508
531
|
const fetcher = options.fetcher ?? fetch;
|
|
509
532
|
const relayUrl = new URL(options.relayUrl).origin;
|
|
533
|
+
const headers = { authorization: `Bearer ${options.token}` };
|
|
510
534
|
let after = 0;
|
|
511
535
|
return {
|
|
512
536
|
async openUrl(url) {
|
|
513
537
|
const response = await fetcher(`${relayUrl}/v1/requests`, {
|
|
514
538
|
method: "POST",
|
|
515
|
-
headers: { "content-type": "application/json" },
|
|
539
|
+
headers: { "content-type": "application/json", ...headers },
|
|
516
540
|
body: JSON.stringify({ sender: options.sender, url })
|
|
517
541
|
});
|
|
518
542
|
if (!response.ok)
|
|
@@ -526,7 +550,7 @@ function createRelayWebLinkTransport(options) {
|
|
|
526
550
|
return;
|
|
527
551
|
polling = true;
|
|
528
552
|
try {
|
|
529
|
-
const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}
|
|
553
|
+
const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { headers });
|
|
530
554
|
if (!response.ok)
|
|
531
555
|
return;
|
|
532
556
|
const messages = (await response.json());
|
|
@@ -534,6 +558,9 @@ function createRelayWebLinkTransport(options) {
|
|
|
534
558
|
after = Math.max(after, message.seq);
|
|
535
559
|
listener(message.url);
|
|
536
560
|
}
|
|
561
|
+
if (messages.length > 0) {
|
|
562
|
+
await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`, { method: "DELETE", headers });
|
|
563
|
+
}
|
|
537
564
|
}
|
|
538
565
|
catch {
|
|
539
566
|
// The next poll retries while the local demo stack starts or restarts.
|
|
@@ -552,4 +579,4 @@ function createRelayWebLinkTransport(options) {
|
|
|
552
579
|
};
|
|
553
580
|
}
|
|
554
581
|
|
|
555
|
-
export { createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
|
|
582
|
+
export { browserSessionKey, createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
|
package/package.json
CHANGED
package/src/handler.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { decodeRequest, encodeResponse } from "./codec"
|
|
2
2
|
import { LinkError } from "./errors"
|
|
3
|
+
import { LINK_ERROR_MESSAGES } from "./messages"
|
|
3
4
|
import { paramsSchemas, resultSchemas } from "./schemas"
|
|
4
5
|
import {
|
|
5
6
|
LINK_OPS,
|
|
@@ -38,8 +39,12 @@ type AnyLinkHandler = (params: unknown, context: { caller: Caller }) => Promise<
|
|
|
38
39
|
export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
|
|
39
40
|
const { handlers, consent } = options
|
|
40
41
|
|
|
41
|
-
function respond(
|
|
42
|
-
|
|
42
|
+
function respond(
|
|
43
|
+
request: DecodedLinkRequest,
|
|
44
|
+
code: LinkErrorCode,
|
|
45
|
+
message: string = LINK_ERROR_MESSAGES[code]
|
|
46
|
+
): string {
|
|
47
|
+
const cappedMessage = message.slice(0, MAX_RESPONSE_MESSAGE_LENGTH)
|
|
43
48
|
return encodeResponse(request.callback, {
|
|
44
49
|
id: request.id,
|
|
45
50
|
version: "v1",
|
|
@@ -86,7 +91,7 @@ export function createRequestHandler(options: RequestHandlerOptions): RequestHan
|
|
|
86
91
|
return encodeResponse(request.callback, response)
|
|
87
92
|
} catch (error) {
|
|
88
93
|
if (error instanceof LinkError) {
|
|
89
|
-
return respond(request, error.code
|
|
94
|
+
return respond(request, error.code)
|
|
90
95
|
}
|
|
91
96
|
return respond(request, "INTERNAL")
|
|
92
97
|
}
|
package/src/index.ts
CHANGED
package/src/messages.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LinkErrorCode } from "./types"
|
|
2
|
+
|
|
3
|
+
/** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
|
|
4
|
+
export const LINK_ERROR_MESSAGES: Record<LinkErrorCode, string> = {
|
|
5
|
+
USER_DENIED: "The person declined the request.",
|
|
6
|
+
CONSENT_DENIED: "The person declined to share.",
|
|
7
|
+
UNSUPPORTED_OP: "This wallet does not support that operation.",
|
|
8
|
+
INVALID_REQUEST: "The request was not valid.",
|
|
9
|
+
NOT_ONBOARDED: "The wallet has no identity yet.",
|
|
10
|
+
NO_CREDENTIAL: "No matching credential.",
|
|
11
|
+
CREDENTIAL_REVOKED: "The matching credential has been revoked.",
|
|
12
|
+
PROOF_FAILED: "The wallet could not build the proof.",
|
|
13
|
+
ISSUER_UNREACHABLE: "The wallet could not use the issuer's server.",
|
|
14
|
+
VERIFIER_UNREACHABLE: "The wallet could not use the verifier's server.",
|
|
15
|
+
ISSUER_NOT_APPROVED: "The credential's issuer key is not approved on chain.",
|
|
16
|
+
CHAIN_UNREACHABLE: "The wallet could not read the chain.",
|
|
17
|
+
INTERNAL: "The wallet failed."
|
|
18
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { base64UrlDecode, base64UrlEncode, MAX_URL_LENGTH } from "./codec"
|
|
3
|
+
import { presentationCreateParamsSchema } from "./schemas"
|
|
4
|
+
|
|
5
|
+
const PREFIX = "cn-present:v1:"
|
|
6
|
+
const publicBase = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^https?:\/\/[^/?#@\s]+(?:\/[^?#@\s]*)?$/, "Server base must not contain credentials, query or fragment")
|
|
9
|
+
const schema = z
|
|
10
|
+
.object({
|
|
11
|
+
params: presentationCreateParamsSchema,
|
|
12
|
+
expiresAt: z.number().int().positive()
|
|
13
|
+
})
|
|
14
|
+
.strict()
|
|
15
|
+
|
|
16
|
+
export type PresentationQr = z.infer<typeof schema>
|
|
17
|
+
|
|
18
|
+
/** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
|
|
19
|
+
export function encodePresentationQr(value: PresentationQr): string {
|
|
20
|
+
const parsed = schema.parse(value)
|
|
21
|
+
const { params, expiresAt } = parsed
|
|
22
|
+
const base = params.responseUrl.replace(/\/v1\/verify$/, "")
|
|
23
|
+
if (
|
|
24
|
+
base === params.responseUrl ||
|
|
25
|
+
params.issuersUrl !== `${base}/v1/issuers` ||
|
|
26
|
+
params.revocationsUrl !== `${base}/v1/revocations/snapshot`
|
|
27
|
+
)
|
|
28
|
+
throw new Error("Unsupported presentation endpoints")
|
|
29
|
+
publicBase.parse(base)
|
|
30
|
+
// Compact keys and one shared endpoint keep the QR readable at phone-screen sizes.
|
|
31
|
+
const wire = {
|
|
32
|
+
r: params.requestId,
|
|
33
|
+
c: params.challenge,
|
|
34
|
+
a: params.audience,
|
|
35
|
+
t: params.currentTime,
|
|
36
|
+
p: params.programme,
|
|
37
|
+
k: params.country,
|
|
38
|
+
u: base,
|
|
39
|
+
e: expiresAt
|
|
40
|
+
}
|
|
41
|
+
const encoded = PREFIX + base64UrlEncode(new TextEncoder().encode(JSON.stringify(wire)))
|
|
42
|
+
if (encoded.length > MAX_URL_LENGTH) throw new Error("Presentation QR is too large")
|
|
43
|
+
return encoded
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function decodePresentationQr(raw: string): PresentationQr {
|
|
47
|
+
if (!raw.startsWith(PREFIX) || raw.length > MAX_URL_LENGTH) throw new Error("Not a presentation QR")
|
|
48
|
+
try {
|
|
49
|
+
const wire = z
|
|
50
|
+
.object({
|
|
51
|
+
r: z.string(),
|
|
52
|
+
c: z.string(),
|
|
53
|
+
a: z.string(),
|
|
54
|
+
t: z.number(),
|
|
55
|
+
p: z.string(),
|
|
56
|
+
k: z.string(),
|
|
57
|
+
u: publicBase,
|
|
58
|
+
e: z.number()
|
|
59
|
+
})
|
|
60
|
+
.strict()
|
|
61
|
+
.parse(JSON.parse(new TextDecoder().decode(base64UrlDecode(raw.slice(PREFIX.length)))))
|
|
62
|
+
return schema.parse({
|
|
63
|
+
expiresAt: wire.e,
|
|
64
|
+
params: {
|
|
65
|
+
requestId: wire.r,
|
|
66
|
+
challenge: wire.c,
|
|
67
|
+
audience: wire.a,
|
|
68
|
+
currentTime: wire.t,
|
|
69
|
+
programme: wire.p,
|
|
70
|
+
country: wire.k,
|
|
71
|
+
issuersUrl: `${wire.u}/v1/issuers`,
|
|
72
|
+
revocationsUrl: `${wire.u}/v1/revocations/snapshot`,
|
|
73
|
+
responseUrl: `${wire.u}/v1/verify`
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error("Invalid presentation QR")
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -160,6 +160,10 @@ export const LINK_ERROR_CODES = [
|
|
|
160
160
|
"PROOF_FAILED",
|
|
161
161
|
"ISSUER_UNREACHABLE",
|
|
162
162
|
"VERIFIER_UNREACHABLE",
|
|
163
|
+
/** The credential's issuer key is not among those the chain lists for the member the caller named. */
|
|
164
|
+
"ISSUER_NOT_APPROVED",
|
|
165
|
+
/** A chain read that would check the issuer keys or the current roots could not be made, so nothing was issued or proved. */
|
|
166
|
+
"CHAIN_UNREACHABLE",
|
|
163
167
|
"INTERNAL"
|
|
164
168
|
] as const
|
|
165
169
|
|
package/src/web.ts
CHANGED
|
@@ -271,7 +271,29 @@ export function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransport
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
|
|
275
|
+
export function browserSessionKey(serviceUrl: string, host: Window = window, fragmentKey = "relayKey"): string {
|
|
276
|
+
const storageKey = `coordination-session-key:${fragmentKey}:${new URL(serviceUrl).origin}`
|
|
277
|
+
const current = new URL(host.location.href)
|
|
278
|
+
const fragment = new URLSearchParams(current.hash.slice(1))
|
|
279
|
+
const paired = fragment.get(fragmentKey)
|
|
280
|
+
if (paired !== null) {
|
|
281
|
+
fragment.delete(fragmentKey)
|
|
282
|
+
current.hash = fragment.toString()
|
|
283
|
+
host.history.replaceState(null, "", current.toString())
|
|
284
|
+
}
|
|
285
|
+
const token =
|
|
286
|
+
paired ??
|
|
287
|
+
host.sessionStorage.getItem(storageKey) ??
|
|
288
|
+
host.prompt(fragmentKey === "memberKey" ? "Member connection key" : "Demo connection key")?.trim()
|
|
289
|
+
if (!token || !/^[0-9a-f]{64}$/i.test(token)) throw new Error("A demo connection key is required")
|
|
290
|
+
host.sessionStorage.setItem(storageKey, token)
|
|
291
|
+
return token
|
|
292
|
+
}
|
|
293
|
+
|
|
274
294
|
export interface RelayWebLinkTransportOptions {
|
|
295
|
+
/** Private key for this demo relay session; never put it in stack.json. */
|
|
296
|
+
token: string
|
|
275
297
|
relayUrl: string
|
|
276
298
|
sender: string
|
|
277
299
|
pollIntervalMs?: number
|
|
@@ -284,12 +306,13 @@ export function createRelayWebLinkTransport(
|
|
|
284
306
|
): Pick<LinkClientOptions, "openUrl" | "onUrl"> {
|
|
285
307
|
const fetcher = options.fetcher ?? fetch
|
|
286
308
|
const relayUrl = new URL(options.relayUrl).origin
|
|
309
|
+
const headers = { authorization: `Bearer ${options.token}` }
|
|
287
310
|
let after = 0
|
|
288
311
|
return {
|
|
289
312
|
async openUrl(url) {
|
|
290
313
|
const response = await fetcher(`${relayUrl}/v1/requests`, {
|
|
291
314
|
method: "POST",
|
|
292
|
-
headers: { "content-type": "application/json" },
|
|
315
|
+
headers: { "content-type": "application/json", ...headers },
|
|
293
316
|
body: JSON.stringify({ sender: options.sender, url })
|
|
294
317
|
})
|
|
295
318
|
if (!response.ok) throw new Error("wallet request could not be delivered")
|
|
@@ -302,7 +325,8 @@ export function createRelayWebLinkTransport(
|
|
|
302
325
|
polling = true
|
|
303
326
|
try {
|
|
304
327
|
const response = await fetcher(
|
|
305
|
-
`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}
|
|
328
|
+
`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`,
|
|
329
|
+
{ headers }
|
|
306
330
|
)
|
|
307
331
|
if (!response.ok) return
|
|
308
332
|
const messages = (await response.json()) as Array<{ seq: number; url: string }>
|
|
@@ -310,6 +334,12 @@ export function createRelayWebLinkTransport(
|
|
|
310
334
|
after = Math.max(after, message.seq)
|
|
311
335
|
listener(message.url)
|
|
312
336
|
}
|
|
337
|
+
if (messages.length > 0) {
|
|
338
|
+
await fetcher(
|
|
339
|
+
`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`,
|
|
340
|
+
{ method: "DELETE", headers }
|
|
341
|
+
)
|
|
342
|
+
}
|
|
313
343
|
} catch {
|
|
314
344
|
// The next poll retries while the local demo stack starts or restarts.
|
|
315
345
|
} finally {
|
package/types/index.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ interface DecodedLinkRequest {
|
|
|
139
139
|
op: string;
|
|
140
140
|
params: unknown;
|
|
141
141
|
}
|
|
142
|
-
declare const LINK_ERROR_CODES: readonly ["USER_DENIED", "CONSENT_DENIED", "UNSUPPORTED_OP", "INVALID_REQUEST", "NOT_ONBOARDED", "NO_CREDENTIAL", "CREDENTIAL_REVOKED", "PROOF_FAILED", "ISSUER_UNREACHABLE", "VERIFIER_UNREACHABLE", "INTERNAL"];
|
|
142
|
+
declare const LINK_ERROR_CODES: readonly ["USER_DENIED", "CONSENT_DENIED", "UNSUPPORTED_OP", "INVALID_REQUEST", "NOT_ONBOARDED", "NO_CREDENTIAL", "CREDENTIAL_REVOKED", "PROOF_FAILED", "ISSUER_UNREACHABLE", "VERIFIER_UNREACHABLE", "ISSUER_NOT_APPROVED", "CHAIN_UNREACHABLE", "INTERNAL"];
|
|
143
143
|
type LinkErrorCode = (typeof LINK_ERROR_CODES)[number];
|
|
144
144
|
/** The response envelope. Unlike the request, it carries no `op`: the caller already knows which op it asked for. */
|
|
145
145
|
type LinkResponse<Op extends LinkOp = LinkOp> = (Op extends LinkOp ? {
|
|
@@ -348,6 +348,8 @@ declare const linkErrorCodeSchema: z.ZodEnum<{
|
|
|
348
348
|
PROOF_FAILED: "PROOF_FAILED";
|
|
349
349
|
ISSUER_UNREACHABLE: "ISSUER_UNREACHABLE";
|
|
350
350
|
VERIFIER_UNREACHABLE: "VERIFIER_UNREACHABLE";
|
|
351
|
+
ISSUER_NOT_APPROVED: "ISSUER_NOT_APPROVED";
|
|
352
|
+
CHAIN_UNREACHABLE: "CHAIN_UNREACHABLE";
|
|
351
353
|
INTERNAL: "INTERNAL";
|
|
352
354
|
}>;
|
|
353
355
|
declare const linkResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -370,6 +372,8 @@ declare const linkResponseSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
370
372
|
PROOF_FAILED: "PROOF_FAILED";
|
|
371
373
|
ISSUER_UNREACHABLE: "ISSUER_UNREACHABLE";
|
|
372
374
|
VERIFIER_UNREACHABLE: "VERIFIER_UNREACHABLE";
|
|
375
|
+
ISSUER_NOT_APPROVED: "ISSUER_NOT_APPROVED";
|
|
376
|
+
CHAIN_UNREACHABLE: "CHAIN_UNREACHABLE";
|
|
373
377
|
INTERNAL: "INTERNAL";
|
|
374
378
|
}>;
|
|
375
379
|
message: z.ZodOptional<z.ZodString>;
|
|
@@ -436,5 +440,27 @@ interface RequestHandler {
|
|
|
436
440
|
}
|
|
437
441
|
declare function createRequestHandler(options: RequestHandlerOptions): RequestHandler;
|
|
438
442
|
|
|
439
|
-
|
|
440
|
-
|
|
443
|
+
/** The only error text that leaves the wallet, bar the zod text for malformed params; other detail stays on the device. */
|
|
444
|
+
declare const LINK_ERROR_MESSAGES: Record<LinkErrorCode, string>;
|
|
445
|
+
|
|
446
|
+
declare const schema: z.ZodObject<{
|
|
447
|
+
params: z.ZodObject<{
|
|
448
|
+
requestId: z.ZodString;
|
|
449
|
+
challenge: z.ZodPipe<z.ZodString, z.ZodTransform<`0x${string}`, string>>;
|
|
450
|
+
audience: z.ZodString;
|
|
451
|
+
currentTime: z.ZodNumber;
|
|
452
|
+
programme: z.ZodString;
|
|
453
|
+
country: z.ZodString;
|
|
454
|
+
issuersUrl: z.ZodString;
|
|
455
|
+
revocationsUrl: z.ZodString;
|
|
456
|
+
responseUrl: z.ZodString;
|
|
457
|
+
}, z.core.$strict>;
|
|
458
|
+
expiresAt: z.ZodNumber;
|
|
459
|
+
}, z.core.$strict>;
|
|
460
|
+
type PresentationQr = z.infer<typeof schema>;
|
|
461
|
+
/** Public challenge only. No credential, proof, callback or member bearer token crosses devices. */
|
|
462
|
+
declare function encodePresentationQr(value: PresentationQr): string;
|
|
463
|
+
declare function decodePresentationQr(raw: string): PresentationQr;
|
|
464
|
+
|
|
465
|
+
export { ALLOWED_ENDPOINT_URL_SCHEMES, DENIED_CALLBACK_SCHEMES, LINK_ERROR_CODES, LINK_ERROR_MESSAGES, LINK_OPS, LinkDecodeError, LinkError, LinkPayloadTooLargeError, MAX_URL_LENGTH, SECURITY_LEVELS, USER_APP_SCHEME, approvalScopeSchema, base64UrlDecode, base64UrlEncode, callbackSchema, callerSchema, createLinkClient, createRequestHandler, credentialIssueParamsSchema, credentialIssueResultSchema, decodePresentationQr, decodeRequest, decodeResponse, encodePresentationQr, encodeRequest, encodeResponse, hex20Schema, hex31Schema, hex32Schema, hex65Schema, hex66Schema, linkErrorCodeSchema, linkRequestEnvelopeSchema, linkRequestSchema, linkResponseSchema, paramsSchemas, presentationCreateParamsSchema, presentationCreateResultSchema, registrationSignParamsSchema, registrationSignResultSchema, resultSchemas, securityLevelSchema, signedRegistrationRequestSchema };
|
|
466
|
+
export type { ApprovalScope, Caller, CredentialIssueParams, CredentialIssueResult, DecodedLinkRequest, Hex, Hex20, Hex31, Hex32, Hex65, Hex66, Jwk, LinkClient, LinkClientOptions, LinkErrorCode, LinkHandlers, LinkOp, LinkOpMap, LinkRequest, LinkResponse, Params, PresentationCreateParams, PresentationCreateResult, PresentationQr, RegistrationSignParams, RegistrationSignResult, RequestHandler, RequestHandlerOptions, Result, SecurityLevel, SignedRegistrationRequest };
|
package/types/web.d.ts
CHANGED
|
@@ -68,7 +68,11 @@ interface EmbeddedWebLinkTransportOptions {
|
|
|
68
68
|
}
|
|
69
69
|
/** Carries the browser protocol through an in-page iframe, avoiding popup-blocker behavior. */
|
|
70
70
|
declare function createEmbeddedWebLinkTransport(options: EmbeddedWebLinkTransportOptions): WebLinkTransport;
|
|
71
|
+
/** Reads the private demo key from a pairing fragment or asks the operator once per tab. */
|
|
72
|
+
declare function browserSessionKey(serviceUrl: string, host?: Window, fragmentKey?: string): string;
|
|
71
73
|
interface RelayWebLinkTransportOptions {
|
|
74
|
+
/** Private key for this demo relay session; never put it in stack.json. */
|
|
75
|
+
token: string;
|
|
72
76
|
relayUrl: string;
|
|
73
77
|
sender: string;
|
|
74
78
|
pollIntervalMs?: number;
|
|
@@ -77,5 +81,5 @@ interface RelayWebLinkTransportOptions {
|
|
|
77
81
|
/** Carries unchanged link URLs through the local demo relay between independently opened pages. */
|
|
78
82
|
declare function createRelayWebLinkTransport(options: RelayWebLinkTransportOptions): Pick<LinkClientOptions, "openUrl" | "onUrl">;
|
|
79
83
|
|
|
80
|
-
export { createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
|
|
84
|
+
export { browserSessionKey, createEmbeddedWebLinkTransport, createRelayWebLinkTransport, createWebLinkTransport, respondToWebLinkRequest };
|
|
81
85
|
export type { EmbeddedWebLinkTransportOptions, RelayWebLinkTransportOptions, WebLinkResponderOptions, WebLinkTransport, WebLinkTransportOptions };
|