@awiki/dsh-pap 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +197 -2
- package/cordis.patch.yml +15 -0
- package/docs/verification.md +217 -0
- package/lib/auth-challenge.d.ts +19 -0
- package/lib/auth-challenge.js +136 -0
- package/lib/browser-client.js +65 -0
- package/lib/browser-client.js.map +6 -0
- package/lib/callback-handoff.d.ts +55 -0
- package/lib/callback-handoff.js +33 -0
- package/lib/client/controller.d.ts +107 -0
- package/lib/client/controller.js +106 -0
- package/lib/client/index.d.ts +6 -0
- package/lib/client/index.js +28 -0
- package/lib/client/views.d.ts +5 -0
- package/lib/client/views.js +28 -0
- package/lib/client.d.ts +108 -0
- package/lib/client.js +833 -0
- package/lib/cooldown.d.ts +11 -0
- package/lib/cooldown.js +88 -0
- package/lib/credential-history.d.ts +33 -0
- package/lib/credential-history.js +109 -0
- package/lib/delegation.d.ts +16 -0
- package/lib/delegation.js +59 -0
- package/lib/discovery.d.ts +8 -0
- package/lib/discovery.js +227 -0
- package/lib/errors.d.ts +14 -0
- package/lib/errors.js +17 -0
- package/lib/file-secrets.d.ts +14 -0
- package/lib/file-secrets.js +148 -0
- package/lib/host-secrets.d.ts +16 -0
- package/lib/host-secrets.js +22 -0
- package/lib/host.d.ts +83 -0
- package/lib/host.js +1 -0
- package/lib/index.d.ts +21 -0
- package/lib/index.js +13 -0
- package/lib/keyring.d.ts +10 -0
- package/lib/keyring.js +64 -0
- package/lib/management-remote.d.ts +13 -0
- package/lib/management-remote.js +69 -0
- package/lib/network.d.ts +23 -0
- package/lib/network.js +91 -0
- package/lib/plugin.d.ts +75 -0
- package/lib/plugin.js +473 -0
- package/lib/product-api.d.ts +89 -0
- package/lib/product-api.js +25 -0
- package/lib/product-operations.d.ts +16 -0
- package/lib/product-operations.js +120 -0
- package/lib/protocol-adapter.d.ts +90 -0
- package/lib/protocol-adapter.js +75 -0
- package/lib/remote.d.ts +3 -0
- package/lib/remote.js +17 -0
- package/lib/store.d.ts +14 -0
- package/lib/store.js +128 -0
- package/lib/strict-json.d.ts +2 -0
- package/lib/strict-json.js +111 -0
- package/lib/tools.d.ts +5 -0
- package/lib/tools.js +29 -0
- package/lib/types.d.ts +202 -0
- package/lib/types.js +1 -0
- package/lib/util.d.ts +17 -0
- package/lib/util.js +66 -0
- package/package.json +155 -4
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { PapError } from './errors.js';
|
|
2
|
+
import type { HttpResponse, PapState } from './types.js';
|
|
3
|
+
export declare const resourceCooldownKey: (companyId: string, resource: string) => string;
|
|
4
|
+
export declare const asCooldownKey: (companyId: string, issuer: string) => string;
|
|
5
|
+
export declare function responseHeader(response: HttpResponse, name: string): string | undefined;
|
|
6
|
+
/** Delta-seconds are stored as exact integer milliseconds, never capped to a shorter server window. */
|
|
7
|
+
export declare function retryAfterUntil(value: string | undefined, now: number): string;
|
|
8
|
+
export declare function remainingCooldownMs(state: PapState, key: string, now: number): bigint;
|
|
9
|
+
export declare function cooldownError(state: PapState, key: string, now: number): PapError;
|
|
10
|
+
export declare function assertCooldownElapsed(state: PapState, key: string, now: number): void;
|
|
11
|
+
export declare function recordCooldown(state: PapState, key: string, response: HttpResponse, now: number): void;
|
package/lib/cooldown.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { PapError, requirePap } from './errors.js';
|
|
2
|
+
import { sha256 } from './util.js';
|
|
3
|
+
export const resourceCooldownKey = (companyId, resource) => sha256(JSON.stringify(['resource', companyId, resource]));
|
|
4
|
+
export const asCooldownKey = (companyId, issuer) => sha256(JSON.stringify(['authorization_server', companyId, issuer]));
|
|
5
|
+
export function responseHeader(response, name) {
|
|
6
|
+
const found = Object.entries(response.headers).filter(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
7
|
+
return found.length === 1 ? found[0][1] : undefined;
|
|
8
|
+
}
|
|
9
|
+
const nowValue = (now) => { requirePap(Number.isSafeInteger(Math.floor(now)) && now >= 0, 'state_conflict'); return BigInt(Math.floor(now)); };
|
|
10
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
11
|
+
function httpDate(value, now) {
|
|
12
|
+
let year;
|
|
13
|
+
let month;
|
|
14
|
+
let day;
|
|
15
|
+
let hour;
|
|
16
|
+
let minute;
|
|
17
|
+
let second;
|
|
18
|
+
const modern = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{2}) ([A-Z][a-z]{2}) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/);
|
|
19
|
+
const old = value.match(/^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([0-9]{2})-([A-Z][a-z]{2})-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/);
|
|
20
|
+
const ascii = value.match(/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ 0-9][0-9]) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/);
|
|
21
|
+
if (modern || old) {
|
|
22
|
+
const match = (modern ?? old);
|
|
23
|
+
day = Number(match[1]);
|
|
24
|
+
month = months.indexOf(match[2]);
|
|
25
|
+
year = Number(match[3]);
|
|
26
|
+
hour = Number(match[4]);
|
|
27
|
+
minute = Number(match[5]);
|
|
28
|
+
second = Number(match[6]);
|
|
29
|
+
if (old) {
|
|
30
|
+
const currentYear = new Date(now).getUTCFullYear();
|
|
31
|
+
year += Math.floor(currentYear / 100) * 100;
|
|
32
|
+
if (year > currentYear + 50)
|
|
33
|
+
year -= 100;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (ascii) {
|
|
37
|
+
month = months.indexOf(ascii[1]);
|
|
38
|
+
day = Number(ascii[2]);
|
|
39
|
+
hour = Number(ascii[3]);
|
|
40
|
+
minute = Number(ascii[4]);
|
|
41
|
+
second = Number(ascii[5]);
|
|
42
|
+
year = Number(ascii[6]);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
return undefined;
|
|
46
|
+
if (month < 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59)
|
|
47
|
+
return undefined;
|
|
48
|
+
const date = new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
49
|
+
if (!Number.isFinite(date.getTime()) || date.getUTCFullYear() !== year || date.getUTCMonth() !== month || date.getUTCDate() !== day)
|
|
50
|
+
return undefined;
|
|
51
|
+
return date.getTime();
|
|
52
|
+
}
|
|
53
|
+
/** Delta-seconds are stored as exact integer milliseconds, never capped to a shorter server window. */
|
|
54
|
+
export function retryAfterUntil(value, now) {
|
|
55
|
+
const current = nowValue(now);
|
|
56
|
+
const input = value?.trim();
|
|
57
|
+
if (input && /^[0-9]{1,16384}$/.test(input))
|
|
58
|
+
return (current + BigInt(input) * 1000n).toString();
|
|
59
|
+
if (input && input.length <= 128) {
|
|
60
|
+
const date = httpDate(input, now);
|
|
61
|
+
if (date !== undefined)
|
|
62
|
+
return (BigInt(Math.trunc(date)) > current ? BigInt(Math.trunc(date)) : current).toString();
|
|
63
|
+
}
|
|
64
|
+
// Missing/malformed Retry-After gets a local one-second fence, not an inferred remote lifetime.
|
|
65
|
+
return (current + 1000n).toString();
|
|
66
|
+
}
|
|
67
|
+
export function remainingCooldownMs(state, key, now) {
|
|
68
|
+
const record = state.cooldowns[key];
|
|
69
|
+
if (!record)
|
|
70
|
+
return 0n;
|
|
71
|
+
requirePap(/^[0-9]{1,16416}$/.test(record.notBeforeMs), 'state_corrupt');
|
|
72
|
+
const remaining = BigInt(record.notBeforeMs) - nowValue(now);
|
|
73
|
+
return remaining > 0n ? remaining : 0n;
|
|
74
|
+
}
|
|
75
|
+
export function cooldownError(state, key, now) {
|
|
76
|
+
const seconds = (remainingCooldownMs(state, key, now) + 999n) / 1000n;
|
|
77
|
+
return new PapError('rate_limited', seconds <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(seconds) : seconds.toString());
|
|
78
|
+
}
|
|
79
|
+
export function assertCooldownElapsed(state, key, now) {
|
|
80
|
+
if (remainingCooldownMs(state, key, now) > 0n)
|
|
81
|
+
throw cooldownError(state, key, now);
|
|
82
|
+
}
|
|
83
|
+
export function recordCooldown(state, key, response, now) {
|
|
84
|
+
const until = retryAfterUntil(responseHeader(response, 'retry-after'), now);
|
|
85
|
+
const previous = state.cooldowns[key];
|
|
86
|
+
if (!previous || BigInt(until) > BigInt(previous.notBeforeMs))
|
|
87
|
+
state.cooldowns[key] = { notBeforeMs: until, generation: (previous?.generation ?? 0) + 1 };
|
|
88
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PapState } from './types.js';
|
|
2
|
+
export declare function ensureCredentialVault(state: PapState): NonNullable<PapState['credentialVault']>;
|
|
3
|
+
/** Called inside existing encrypted state transactions before replacing/clearing a credential. */
|
|
4
|
+
export declare function archiveCredentials(state: PapState, values: readonly (string | undefined)[]): void;
|
|
5
|
+
export interface CredentialObservation {
|
|
6
|
+
accessToken: string;
|
|
7
|
+
accessExpiresInMs?: number;
|
|
8
|
+
refreshToken?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Service-private redaction history. Live/unknown credentials are never evicted to fit a budget. */
|
|
11
|
+
export declare class CredentialHistory {
|
|
12
|
+
private readonly clock;
|
|
13
|
+
private readonly maxEntries;
|
|
14
|
+
private readonly maxBytes;
|
|
15
|
+
private readonly entries;
|
|
16
|
+
private readonly active;
|
|
17
|
+
private bytes;
|
|
18
|
+
private blocked;
|
|
19
|
+
private closed;
|
|
20
|
+
constructor(clock?: () => number, maxEntries?: number, maxBytes?: number);
|
|
21
|
+
private sweep;
|
|
22
|
+
assertUsable(): void;
|
|
23
|
+
restore(state: PapState): void;
|
|
24
|
+
/** Never throws while an AS response is being persisted; a limit latches public output closed. */
|
|
25
|
+
observe(input: CredentialObservation): void;
|
|
26
|
+
/** Restored values have no trustworthy process-monotonic remaining lifetime. */
|
|
27
|
+
rememberIfAbsent(secret: string): void;
|
|
28
|
+
private remember;
|
|
29
|
+
begin(): symbol;
|
|
30
|
+
values(): string[];
|
|
31
|
+
end(pin: symbol): void;
|
|
32
|
+
dispose(): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { PapError } from './errors.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
const MAX_ENTRIES = 4096;
|
|
4
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
export function ensureCredentialVault(state) {
|
|
6
|
+
if (!state.credentialVault) {
|
|
7
|
+
const accountEvidence = Object.keys(state.grants).length > 0 || Object.keys(state.transactions).length > 0 || Object.keys(state.accountSlots ?? {}).length > 0
|
|
8
|
+
|| Object.keys(state.nonces).some(key => key.startsWith('authorization:')) || Object.values(state.retirements).some(value => value.key.purpose === 'pap_dpop');
|
|
9
|
+
state.credentialVault = { version: 1, completeness: accountEvidence ? 'legacy_incomplete' : 'complete', entries: {}, blocked: false };
|
|
10
|
+
// Seeding known current values never claims to reconstruct older replaced credentials.
|
|
11
|
+
if (accountEvidence)
|
|
12
|
+
for (const grant of Object.values(state.grants))
|
|
13
|
+
archiveCredentials(state, [grant.accessToken, grant.refreshToken]);
|
|
14
|
+
}
|
|
15
|
+
return state.credentialVault;
|
|
16
|
+
}
|
|
17
|
+
/** Called inside existing encrypted state transactions before replacing/clearing a credential. */
|
|
18
|
+
export function archiveCredentials(state, values) {
|
|
19
|
+
const vault = ensureCredentialVault(state);
|
|
20
|
+
let bytes = Object.values(vault.entries).reduce((total, value) => total + Buffer.byteLength(value), 0);
|
|
21
|
+
for (const value of values) {
|
|
22
|
+
if (!value)
|
|
23
|
+
continue;
|
|
24
|
+
const key = createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
25
|
+
if (vault.entries[key] !== undefined)
|
|
26
|
+
continue;
|
|
27
|
+
const size = Buffer.byteLength(value);
|
|
28
|
+
if (Object.keys(vault.entries).length >= MAX_ENTRIES || bytes + size > MAX_BYTES) {
|
|
29
|
+
vault.blocked = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
vault.entries[key] = value;
|
|
33
|
+
bytes += size;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Service-private redaction history. Live/unknown credentials are never evicted to fit a budget. */
|
|
37
|
+
export class CredentialHistory {
|
|
38
|
+
clock;
|
|
39
|
+
maxEntries;
|
|
40
|
+
maxBytes;
|
|
41
|
+
entries = new Map();
|
|
42
|
+
active = new Set();
|
|
43
|
+
bytes = 0;
|
|
44
|
+
blocked = false;
|
|
45
|
+
closed = false;
|
|
46
|
+
constructor(clock = () => performance.now(), maxEntries = MAX_ENTRIES, maxBytes = MAX_BYTES) {
|
|
47
|
+
this.clock = clock;
|
|
48
|
+
this.maxEntries = maxEntries;
|
|
49
|
+
this.maxBytes = maxBytes;
|
|
50
|
+
}
|
|
51
|
+
sweep() {
|
|
52
|
+
const now = this.clock();
|
|
53
|
+
for (const [secret, entry] of this.entries)
|
|
54
|
+
if (entry.deadline <= now && entry.pins.size === 0) {
|
|
55
|
+
this.entries.delete(secret);
|
|
56
|
+
this.bytes -= Buffer.byteLength(secret);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
assertUsable() { if (this.closed || this.blocked)
|
|
60
|
+
throw new PapError('credential_unavailable'); }
|
|
61
|
+
restore(state) {
|
|
62
|
+
if (state.credentialVault?.blocked)
|
|
63
|
+
this.blocked = true;
|
|
64
|
+
for (const value of Object.values(state.credentialVault?.entries ?? {}))
|
|
65
|
+
this.rememberIfAbsent(value);
|
|
66
|
+
}
|
|
67
|
+
/** Never throws while an AS response is being persisted; a limit latches public output closed. */
|
|
68
|
+
observe(input) {
|
|
69
|
+
this.remember(input.accessToken, input.accessExpiresInMs);
|
|
70
|
+
if (input.refreshToken)
|
|
71
|
+
this.remember(input.refreshToken);
|
|
72
|
+
}
|
|
73
|
+
/** Restored values have no trustworthy process-monotonic remaining lifetime. */
|
|
74
|
+
rememberIfAbsent(secret) { if (secret && !this.entries.has(secret))
|
|
75
|
+
this.remember(secret); }
|
|
76
|
+
remember(secret, lifetime) {
|
|
77
|
+
if (!secret || this.closed || this.blocked)
|
|
78
|
+
return;
|
|
79
|
+
this.sweep();
|
|
80
|
+
const deadline = lifetime === undefined || !Number.isFinite(lifetime) ? Infinity : this.clock() + Math.max(0, lifetime);
|
|
81
|
+
const previous = this.entries.get(secret);
|
|
82
|
+
if (previous) {
|
|
83
|
+
previous.deadline = Math.max(previous.deadline, deadline);
|
|
84
|
+
for (const pin of this.active)
|
|
85
|
+
previous.pins.add(pin);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const size = Buffer.byteLength(secret);
|
|
89
|
+
if (this.entries.size >= this.maxEntries || this.bytes + size > this.maxBytes) {
|
|
90
|
+
this.blocked = true;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.entries.set(secret, { deadline, pins: new Set(this.active) });
|
|
94
|
+
this.bytes += size;
|
|
95
|
+
}
|
|
96
|
+
begin() {
|
|
97
|
+
this.assertUsable();
|
|
98
|
+
this.sweep();
|
|
99
|
+
const pin = Symbol();
|
|
100
|
+
this.active.add(pin);
|
|
101
|
+
for (const entry of this.entries.values())
|
|
102
|
+
entry.pins.add(pin);
|
|
103
|
+
return pin;
|
|
104
|
+
}
|
|
105
|
+
values() { this.assertUsable(); this.sweep(); return [...this.entries.keys()]; }
|
|
106
|
+
end(pin) { this.active.delete(pin); for (const entry of this.entries.values())
|
|
107
|
+
entry.pins.delete(pin); this.sweep(); }
|
|
108
|
+
dispose() { this.closed = true; this.entries.clear(); this.active.clear(); this.bytes = 0; }
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PapSigningLease } from './host.js';
|
|
2
|
+
import type { Delegation, KeyRef, PublicJwk, Subject } from './types.js';
|
|
3
|
+
export declare function thumbprint(jwk: PublicJwk): string;
|
|
4
|
+
export declare function verifyCompact(jwt: string, jwk: PublicJwk): boolean;
|
|
5
|
+
export declare function checkDelegation(jwt: string, key: KeyRef, clientId: string, identity: {
|
|
6
|
+
alg: string;
|
|
7
|
+
kid: string;
|
|
8
|
+
publicJwk?: PublicJwk;
|
|
9
|
+
}, now: number): number;
|
|
10
|
+
export declare class DelegationAttemptFailure extends Error {
|
|
11
|
+
readonly code: import('./errors.js').PapErrorCode;
|
|
12
|
+
readonly key: KeyRef;
|
|
13
|
+
readonly retired: boolean;
|
|
14
|
+
constructor(code: import('./errors.js').PapErrorCode, key: KeyRef, retired: boolean);
|
|
15
|
+
}
|
|
16
|
+
export declare function newDelegation(subject: Subject, lease: PapSigningLease, generation: number, now: number, operationId: string): Promise<Delegation>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { PapError, requirePap } from './errors.js';
|
|
3
|
+
import { jwtParts, scopeFor, sha256 } from './util.js';
|
|
4
|
+
export function thumbprint(jwk) { return sha256(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })); }
|
|
5
|
+
export function verifyCompact(jwt, jwk) {
|
|
6
|
+
try {
|
|
7
|
+
const [head, payload, signature] = jwt.split('.');
|
|
8
|
+
return verify('sha256', Buffer.from(`${head}.${payload}`), { key: createPublicKey({ key: jwk, format: 'jwk' }), dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url'));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function checkDelegation(jwt, key, clientId, identity, now) {
|
|
15
|
+
const { header, payload } = jwtParts(jwt);
|
|
16
|
+
requirePap(identity.alg === 'ES256' && identity.publicJwk && header.alg === 'ES256' && header.typ === 'poppy-delegation+jwt' && header.kid === identity.kid && verifyCompact(jwt, identity.publicJwk), 'delegation_blocked');
|
|
17
|
+
requirePap(Object.keys(payload).sort().join(',') === 'cnf,exp,iat,iss,jti', 'delegation_blocked');
|
|
18
|
+
const cnf = payload.cnf;
|
|
19
|
+
requirePap(cnf?.jwk && Object.keys(cnf.jwk).sort().join(',') === 'crv,kty,x,y' && cnf.jwk.kty === 'EC' && cnf.jwk.crv === 'P-256', 'delegation_blocked');
|
|
20
|
+
requirePap(thumbprint(cnf.jwk) === key.thumbprint && key.thumbprint === thumbprint(key.public_jwk), 'delegation_blocked');
|
|
21
|
+
requirePap(payload.iss === clientId && Number.isSafeInteger(payload.iat) && Number.isSafeInteger(payload.exp) && typeof payload.jti === 'string' && payload.jti.length > 0, 'delegation_blocked');
|
|
22
|
+
const iat = Number(payload.iat);
|
|
23
|
+
const exp = Number(payload.exp);
|
|
24
|
+
requirePap(iat <= Math.floor(now / 1000) + 5 && exp * 1000 > now && exp > iat && exp - iat <= 86400, 'delegation_blocked');
|
|
25
|
+
return exp * 1000;
|
|
26
|
+
}
|
|
27
|
+
export class DelegationAttemptFailure extends Error {
|
|
28
|
+
code;
|
|
29
|
+
key;
|
|
30
|
+
retired;
|
|
31
|
+
constructor(code, key, retired) {
|
|
32
|
+
super('Delegation attempt failed');
|
|
33
|
+
this.code = code;
|
|
34
|
+
this.key = key;
|
|
35
|
+
this.retired = retired;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function newDelegation(subject, lease, generation, now, operationId) {
|
|
39
|
+
const scope = scopeFor(subject);
|
|
40
|
+
const identity = await lease.getOAuthClientIdentity();
|
|
41
|
+
requirePap(identity.clientId === subject.binding.clientId, 'permission_denied');
|
|
42
|
+
const key = await lease.createDelegatedKey(scope, operationId);
|
|
43
|
+
try {
|
|
44
|
+
requirePap(key.purpose === 'pap_session', 'delegation_blocked');
|
|
45
|
+
const compact = await lease.issuePapDelegation(key, scope, 3600);
|
|
46
|
+
const expiresAt = checkDelegation(compact, key, subject.binding.clientId, identity, now);
|
|
47
|
+
await lease.assertActive();
|
|
48
|
+
return { subjectRef: subject.ref, key, compact, expiresAt, generation };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
let retired = false;
|
|
52
|
+
try {
|
|
53
|
+
await lease.retireTemporaryKey(key, scope);
|
|
54
|
+
retired = true;
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
throw new DelegationAttemptFailure(error instanceof PapError ? error.code : 'delegation_blocked', key, retired);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Company, HttpTransport } from './types.js';
|
|
2
|
+
export declare class PapDiscovery {
|
|
3
|
+
private readonly transport;
|
|
4
|
+
private readonly clock;
|
|
5
|
+
constructor(transport: HttpTransport, clock?: () => number);
|
|
6
|
+
resolve(domain: string): Promise<Company>;
|
|
7
|
+
private discover;
|
|
8
|
+
}
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { PapError, requirePap } from './errors.js';
|
|
2
|
+
import { httpsUrl, id, object, parseJson, sha256, sha256Hex, strings } from './util.js';
|
|
3
|
+
const optionalStrings = (value) => value === undefined ? undefined : strings(value);
|
|
4
|
+
export class PapDiscovery {
|
|
5
|
+
transport;
|
|
6
|
+
clock;
|
|
7
|
+
constructor(transport, clock = () => Date.now()) {
|
|
8
|
+
this.transport = transport;
|
|
9
|
+
this.clock = clock;
|
|
10
|
+
}
|
|
11
|
+
async resolve(domain) {
|
|
12
|
+
try {
|
|
13
|
+
return await this.discover(domain);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof PapError && ['unsupported_profile', 'unsafe_destination'].includes(error.code))
|
|
17
|
+
throw error;
|
|
18
|
+
throw new PapError('discovery_failed');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async discover(domain) {
|
|
22
|
+
requirePap(!/[/?#@\\]/.test(domain) && domain.length > 0, 'unsafe_destination');
|
|
23
|
+
let url = httpsUrl(`https://${domain}/.well-known/poppy.json`);
|
|
24
|
+
const sources = [];
|
|
25
|
+
const started = Date.now();
|
|
26
|
+
let requests = 0;
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
const request = async (target) => {
|
|
29
|
+
requirePap(++requests <= 64 && Date.now() - started < 30000, 'discovery_failed');
|
|
30
|
+
const response = await this.transport.request(target, { signal: AbortSignal.timeout(Math.max(1, Math.min(10000, 30000 - (Date.now() - started)))) });
|
|
31
|
+
bytes += response.body.length;
|
|
32
|
+
requirePap(bytes <= 4 * 1024 * 1024, 'discovery_failed');
|
|
33
|
+
return response;
|
|
34
|
+
};
|
|
35
|
+
let response;
|
|
36
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
37
|
+
response = await request(url.href);
|
|
38
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
39
|
+
requirePap(hop < 5 && response.headers.location, 'discovery_failed');
|
|
40
|
+
url = httpsUrl(new URL(response.headers.location, url).href);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
requirePap(response?.status === 200 && url.pathname === '/.well-known/poppy.json' && !url.search, 'discovery_failed');
|
|
46
|
+
const doc = parseJson(response);
|
|
47
|
+
const organization = object(doc.organization);
|
|
48
|
+
requirePap(doc.protocol_version === '0.3', 'unsupported_profile');
|
|
49
|
+
requirePap(typeof organization.domain === 'string' && organization.domain.toLowerCase() === url.host.toLowerCase() && typeof organization.name === 'string' && organization.name.length > 0, 'discovery_failed');
|
|
50
|
+
// Host comparison includes explicit ports in deliberately configured isolated deployments.
|
|
51
|
+
const origin = `https://${url.host.toLowerCase()}`;
|
|
52
|
+
const record = (response) => { sources.push({ url: response.url, digest: sha256(response.body) }); };
|
|
53
|
+
record(response);
|
|
54
|
+
const read = async (value) => {
|
|
55
|
+
const target = httpsUrl(value);
|
|
56
|
+
const result = await request(target.href);
|
|
57
|
+
requirePap(result.status === 200, 'discovery_failed');
|
|
58
|
+
record(result);
|
|
59
|
+
return parseJson(result);
|
|
60
|
+
};
|
|
61
|
+
const auth = doc.auth === undefined ? {} : object(doc.auth);
|
|
62
|
+
requirePap(auth.disabled === undefined || typeof auth.disabled === 'boolean', 'discovery_failed');
|
|
63
|
+
const prmResponse = await request(`${origin}/.well-known/oauth-protected-resource`);
|
|
64
|
+
let servers = [];
|
|
65
|
+
if (prmResponse.status !== 404) {
|
|
66
|
+
requirePap(prmResponse.status === 200, 'discovery_failed');
|
|
67
|
+
record(prmResponse);
|
|
68
|
+
const prm = parseJson(prmResponse);
|
|
69
|
+
requirePap(prm.resource === origin, 'discovery_failed');
|
|
70
|
+
servers = optionalStrings(prm.authorization_servers) ?? [];
|
|
71
|
+
requirePap(new Set(servers).size === servers.length, 'discovery_failed');
|
|
72
|
+
for (const server of servers)
|
|
73
|
+
httpsUrl(server);
|
|
74
|
+
}
|
|
75
|
+
let issuer;
|
|
76
|
+
if (servers.length === 0)
|
|
77
|
+
requirePap(auth.issuer === undefined && auth.scopes === undefined && auth.scope_hints === undefined && auth.direct === undefined && auth.device === undefined && auth.custom_scopes === undefined, 'discovery_failed');
|
|
78
|
+
else {
|
|
79
|
+
if (servers.length === 1) {
|
|
80
|
+
issuer = servers[0];
|
|
81
|
+
requirePap(auth.issuer === undefined || auth.issuer === issuer, 'discovery_failed');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
requirePap(typeof auth.issuer === 'string' && servers.includes(auth.issuer), 'discovery_failed');
|
|
85
|
+
issuer = auth.issuer;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const advertisedScopes = new Set();
|
|
89
|
+
let metadata;
|
|
90
|
+
if (issuer) {
|
|
91
|
+
const issuerUrl = httpsUrl(issuer);
|
|
92
|
+
requirePap(!issuerUrl.search && !issuerUrl.hash, 'discovery_failed');
|
|
93
|
+
const metadataUrl = `${issuerUrl.origin}/.well-known/oauth-authorization-server${issuerUrl.pathname === '/' ? '' : issuerUrl.pathname}`;
|
|
94
|
+
const meta = await read(metadataUrl);
|
|
95
|
+
if (meta.scopes_supported !== undefined)
|
|
96
|
+
strings(meta.scopes_supported).forEach(scope => advertisedScopes.add(scope));
|
|
97
|
+
requirePap(meta.issuer === issuer, 'discovery_failed');
|
|
98
|
+
const resources = strings(meta.protected_resources);
|
|
99
|
+
requirePap(resources.includes(origin), 'discovery_failed');
|
|
100
|
+
resources.forEach(httpsUrl);
|
|
101
|
+
httpsUrl(meta.token_endpoint);
|
|
102
|
+
if (meta.authorization_endpoint !== undefined)
|
|
103
|
+
httpsUrl(meta.authorization_endpoint);
|
|
104
|
+
if (meta.revocation_endpoint !== undefined)
|
|
105
|
+
httpsUrl(meta.revocation_endpoint);
|
|
106
|
+
metadata = {
|
|
107
|
+
issuer, token_endpoint: meta.token_endpoint, protected_resources: resources,
|
|
108
|
+
authorization_endpoint: meta.authorization_endpoint, revocation_endpoint: meta.revocation_endpoint,
|
|
109
|
+
grant_types_supported: optionalStrings(meta.grant_types_supported) ?? ['authorization_code', 'implicit'],
|
|
110
|
+
token_endpoint_auth_methods_supported: optionalStrings(meta.token_endpoint_auth_methods_supported),
|
|
111
|
+
dpop_signing_alg_values_supported: optionalStrings(meta.dpop_signing_alg_values_supported),
|
|
112
|
+
code_challenge_methods_supported: optionalStrings(meta.code_challenge_methods_supported),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const routes = [];
|
|
116
|
+
const discoveredScopes = new Set();
|
|
117
|
+
const resourceCache = new Map();
|
|
118
|
+
const serviceResource = async (service, mcp = false) => {
|
|
119
|
+
if (resourceCache.has(service))
|
|
120
|
+
return resourceCache.get(service);
|
|
121
|
+
const target = httpsUrl(service);
|
|
122
|
+
const response = await request(`${target.origin}/.well-known/oauth-protected-resource${target.pathname === '/' ? '' : target.pathname}`);
|
|
123
|
+
let resource = origin;
|
|
124
|
+
if (response.status !== 404) {
|
|
125
|
+
requirePap(response.status === 200, 'discovery_failed');
|
|
126
|
+
record(response);
|
|
127
|
+
const metadata = parseJson(response);
|
|
128
|
+
httpsUrl(metadata.resource);
|
|
129
|
+
if (metadata.scopes_supported !== undefined)
|
|
130
|
+
strings(metadata.scopes_supported).forEach(scope => advertisedScopes.add(scope));
|
|
131
|
+
requirePap(metadata.resource === origin || metadata.resource === service, 'discovery_failed');
|
|
132
|
+
if (mcp)
|
|
133
|
+
requirePap(metadata.resource === service, 'discovery_failed');
|
|
134
|
+
if (issuer && metadata.authorization_servers !== undefined)
|
|
135
|
+
requirePap(strings(metadata.authorization_servers).includes(issuer), 'discovery_failed');
|
|
136
|
+
resource = metadata.resource;
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
requirePap(!mcp, 'discovery_failed');
|
|
140
|
+
resourceCache.set(service, resource);
|
|
141
|
+
return resource;
|
|
142
|
+
};
|
|
143
|
+
if (doc.apis !== undefined) {
|
|
144
|
+
requirePap(Array.isArray(doc.apis) && doc.apis.length <= 32, 'discovery_failed');
|
|
145
|
+
for (const item of doc.apis) {
|
|
146
|
+
const api = object(item);
|
|
147
|
+
const apiUrl = httpsUrl(api.url).href;
|
|
148
|
+
if (api.type === 'mcp') {
|
|
149
|
+
// MCP target metadata is checked independently; an account token targets the exact MCP URL.
|
|
150
|
+
await serviceResource(apiUrl, true);
|
|
151
|
+
routes.push({ method: 'POST', template: apiUrl, resource: apiUrl, channel: 'mcp' }, { method: 'GET', template: apiUrl, resource: apiUrl, channel: 'mcp' }, { method: 'DELETE', template: apiUrl, resource: apiUrl, channel: 'mcp' });
|
|
152
|
+
}
|
|
153
|
+
else if (api.type === 'openapi') {
|
|
154
|
+
const spec = await read(apiUrl);
|
|
155
|
+
requirePap(typeof spec.openapi === 'string' && spec.openapi.startsWith('3.'), 'unsupported_profile');
|
|
156
|
+
const servers = spec.servers === undefined ? [{ url: new URL(apiUrl).origin }] : spec.servers;
|
|
157
|
+
requirePap(Array.isArray(servers) && servers.length > 0 && servers.length <= 16, 'discovery_failed');
|
|
158
|
+
const paths = object(spec.paths);
|
|
159
|
+
if (spec.components !== undefined) {
|
|
160
|
+
const schemes = object(spec.components).securitySchemes;
|
|
161
|
+
if (schemes !== undefined)
|
|
162
|
+
for (const scheme of Object.values(object(schemes))) {
|
|
163
|
+
const entry = object(scheme);
|
|
164
|
+
if (entry.type === 'oauth2' && entry.flows !== undefined)
|
|
165
|
+
for (const flow of Object.values(object(entry.flows))) {
|
|
166
|
+
const scopes = object(flow).scopes;
|
|
167
|
+
if (scopes !== undefined)
|
|
168
|
+
Object.keys(object(scopes)).forEach(value => discoveredScopes.add(value));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const server of servers) {
|
|
173
|
+
const base = httpsUrl(new URL(String(object(server).url), apiUrl).href);
|
|
174
|
+
requirePap(!base.search && !base.hash, 'discovery_failed');
|
|
175
|
+
const resource = await serviceResource(base.href.replace(/\/$/, ''));
|
|
176
|
+
for (const [path, value] of Object.entries(paths)) {
|
|
177
|
+
requirePap(path.startsWith('/') && !path.startsWith('//') && !/[?#\\]/.test(path), 'discovery_failed');
|
|
178
|
+
const pathItem = object(value);
|
|
179
|
+
requirePap(pathItem.servers === undefined, 'unsupported_profile');
|
|
180
|
+
for (const [method, operation] of Object.entries(pathItem))
|
|
181
|
+
if (['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(method)) {
|
|
182
|
+
requirePap(object(operation).servers === undefined, 'unsupported_profile');
|
|
183
|
+
const parameters = [];
|
|
184
|
+
for (const list of [pathItem.parameters, object(operation).parameters])
|
|
185
|
+
if (list !== undefined) {
|
|
186
|
+
requirePap(Array.isArray(list) && list.length <= 64, 'discovery_failed');
|
|
187
|
+
for (const raw of list) {
|
|
188
|
+
const parameter = object(raw);
|
|
189
|
+
// Unsupported references are not converted into a permissive tool parameter.
|
|
190
|
+
if (parameter.$ref !== undefined)
|
|
191
|
+
continue;
|
|
192
|
+
if (parameter.in !== 'path' && parameter.in !== 'query')
|
|
193
|
+
continue;
|
|
194
|
+
requirePap(typeof parameter.name === 'string' && /^[A-Za-z0-9_.-]{1,128}$/.test(parameter.name), 'discovery_failed');
|
|
195
|
+
const value = { name: parameter.name, in: parameter.in, required: parameter.required === true };
|
|
196
|
+
const index = parameters.findIndex(p => p.name === value.name && p.in === value.in);
|
|
197
|
+
if (index >= 0)
|
|
198
|
+
parameters[index] = value;
|
|
199
|
+
else
|
|
200
|
+
parameters.push(value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
routes.push({ method: method.toUpperCase(), template: `${base.href.replace(/\/$/, '')}${path}`, resource, parameters, channel: 'openapi' });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const protocols = doc.agent === undefined ? [] : object(doc.agent).protocols;
|
|
211
|
+
if (Array.isArray(protocols))
|
|
212
|
+
for (const item of protocols) {
|
|
213
|
+
const protocol = object(item);
|
|
214
|
+
if (protocol.type === 'poppy') {
|
|
215
|
+
const endpoint = httpsUrl(protocol.endpoint).href.replace(/\/$/, '');
|
|
216
|
+
const resource = await serviceResource(endpoint);
|
|
217
|
+
for (const [method, suffix] of [['POST', ''], ['GET', '/{id}'], ['GET', '/{id}/messages'], ['POST', '/{id}/messages'], ['POST', '/{id}/close']])
|
|
218
|
+
routes.push({ method, template: endpoint + suffix, resource, channel: 'poppy' });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
requirePap(routes.length > 0 && routes.length <= 2048, 'unsupported_profile');
|
|
222
|
+
const scopes = [...new Set([...(auth.scopes === undefined ? [] : Object.keys(object(auth.scopes))), ...discoveredScopes, ...advertisedScopes])];
|
|
223
|
+
return { ref: id('cmp'), domain: url.host.toLowerCase(), name: organization.name, companyId: issuer ?? origin, issuer,
|
|
224
|
+
authDisabled: auth.disabled === true, authMode: issuer ? auth.disabled === true ? 'disabled' : 'account' : 'no_as', metadata,
|
|
225
|
+
routes, scopes, metadataDigest: sha256Hex(JSON.stringify(sources)), sources, expiresAt: this.clock() + 300000, generation: 1 };
|
|
226
|
+
}
|
|
227
|
+
}
|
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PapErrorCode = 'discovery_failed' | 'unsupported_profile' | 'unsafe_destination' | 'namespace_changed' | 'permission_denied' | 'state_conflict' | 'state_corrupt' | 'credential_unavailable' | 'delegation_blocked' | 'invalid_session' | 'session_ended' | 'invalid_token' | 'reauthorization_required' | 'unsupported_sign_in' | 'invalid_authorization_response' | 'insufficient_scope' | 'unauthorized_client' | 'rate_limited' | 'request_uncertain' | 'redirect_blocked' | 'invalid_client' | 'invalid_dpop_proof' | 'invalid_scope' | 'invalid_target' | 'oauth_error' | 'disconnected' | 'revocation_pending' | 'not_configured' | 'credential_history_incomplete';
|
|
2
|
+
export interface PapAuthHint {
|
|
3
|
+
readonly scheme: 'Bearer' | 'DPoP';
|
|
4
|
+
readonly scope?: readonly string[];
|
|
5
|
+
readonly resourceMetadataUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Errors are deliberately fixed text: no remote payloads or credentials. */
|
|
8
|
+
export declare class PapError extends Error {
|
|
9
|
+
readonly code: PapErrorCode;
|
|
10
|
+
readonly retryAfterSeconds?: number | string | undefined;
|
|
11
|
+
readonly authHint?: PapAuthHint | undefined;
|
|
12
|
+
constructor(code: PapErrorCode, retryAfterSeconds?: number | string | undefined, authHint?: PapAuthHint | undefined);
|
|
13
|
+
}
|
|
14
|
+
export declare function requirePap(condition: unknown, code: PapErrorCode): asserts condition;
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Errors are deliberately fixed text: no remote payloads or credentials. */
|
|
2
|
+
export class PapError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
retryAfterSeconds;
|
|
5
|
+
authHint;
|
|
6
|
+
constructor(code, retryAfterSeconds, authHint) {
|
|
7
|
+
super(`PAP operation failed: ${code}`);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
10
|
+
this.authHint = authHint;
|
|
11
|
+
this.name = 'PapError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function requirePap(condition, code) {
|
|
15
|
+
if (!condition)
|
|
16
|
+
throw new PapError(code);
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HostSecretStore } from './host.js';
|
|
2
|
+
/** Flush directories where the OS supports opening them; Windows still flushes files. */
|
|
3
|
+
export declare function syncDirectory(path: string): Promise<void>;
|
|
4
|
+
/** File-backed PAP state encryption keys. Identity/signing keys remain in native custody. */
|
|
5
|
+
export declare class FileSecretStore implements HostSecretStore {
|
|
6
|
+
readonly root: string;
|
|
7
|
+
constructor(root: string);
|
|
8
|
+
assertSeparateFrom(stateRoot: string): void;
|
|
9
|
+
private path;
|
|
10
|
+
private prepare;
|
|
11
|
+
private read;
|
|
12
|
+
getEncryptionKey(namespace: string): Promise<Uint8Array | undefined>;
|
|
13
|
+
getOrCreateEncryptionKey(namespace: string): Promise<Uint8Array>;
|
|
14
|
+
}
|