Skip to content

Commit f714630

Browse files
committed
Make registry authorisation recoverable
1 parent b592c35 commit f714630

9 files changed

Lines changed: 882 additions & 150 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,22 @@
77
opens a 15-minute exact-coordinate wallet session, and resumes publishing
88
automatically after Registry approval; `--no-open` supports remote and
99
terminal-only environments. Session reads expose neither the polling secret
10-
nor the resulting key ID to the browser. Submit distinguishes detected
11-
in-browser connectors from the complete external-signature directory, uses
10+
nor the resulting key ID to the browser. The publishing key is written to
11+
the OS keychain as `pending` before the browser opens, promoted to `active`
12+
only when either successful status returns the matching key ID, and removed
13+
on cancellation or expiry. This closes the process-exit window after wallet
14+
approval without treating local state as Registry authority. The browser
15+
token survives same-tab refresh in `sessionStorage` and is removed on
16+
completion or expiry. Session mode now
17+
lists only connectors that can actually complete the browser flow and folds
18+
challenge creation, wallet signing, and completion into one **Approve
19+
publishing access** action; the full external-wallet directory remains in
20+
the explicit manual CLI path. Session completion atomically consumes the
21+
nonce, records the publishing key, claims or reviews the namespace, updates
22+
the session, and writes its audit trail. Concurrent or replayed completion
23+
returns the committed result without duplicating authority. Submit
24+
distinguishes detected in-browser connectors from the complete
25+
external-signature directory, uses
1226
plain publishing-access language on the first-run path, states the
1327
non-replaceable release rule directly, and preserves the explicit CLI path
1428
for external wallets and CI. Artifact details now derive one recommended

‎docs/CELLSCRIPT_GATE_POLICY.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,15 @@ The CLI coverage includes both first-publish admission paths: the explicit
6868
`cellc auth capability submit`, `cellc auth namespace claim`, then
6969
`cellc publish` sequence, and the short-lived `cellc publish --authorise`
7070
browser session in which the private publishing key remains in the local OS
71-
keychain while the CLI polls with a one-time secret. Publisher maintenance additionally uses the capability-signed
71+
keychain as pending while the CLI polls with a one-time secret, becomes active
72+
only after the server returns the matching key ID, and is removed on terminal
73+
cancellation or expiry. The browser token survives a same-tab refresh but is
74+
cleared after completion or expiry. Browser-session completion is one atomic admission boundary across
75+
nonce consumption, publishing-key registration, namespace claim/review,
76+
session state, and audit events. API tests cover expiry, wrong browser/poll/
77+
challenge tokens, challenge replay, concurrent completion, conflicting
78+
namespace ownership, review-pending admission, and injected mid-transaction
79+
failure. Publisher maintenance additionally uses the capability-signed
7280
`cellc artifact set-availability` path, and `cellc artifact cell-dep` performs a
7381
fresh mainnet liveness check before producing a transaction-builder descriptor.
7482
Independent reproducibility builders use `cellc auth reproducer create`; CLI

‎services/registry-api/README.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,22 @@ scope:
170170
Each form also accepts `namespace/*`. Possessing one action does not imply either
171171
of the others.
172172

173+
`POST /v1/authorisation-sessions/:session_id/complete` is idempotent after a
174+
successful completion. Its first successful call commits nonce use, capability
175+
registration, namespace claim or review state, session completion, and audit
176+
events in one store transaction. A concurrent call returns the committed
177+
session instead of creating a second capability use. Expired sessions, stale
178+
challenge tokens, and conflicting namespace owners leave the session pending
179+
and create none of those records.
180+
173181
For an interactive first publish, `cellc publish --authorise` creates a
174182
15-minute, exact-coordinate browser session and opens the matching Registry
175183
site. The CLI generates the delegated P-256 key first and keeps its private key
176-
in the OS keychain. The API stores only the public key plus hashes of separate
184+
in the OS keychain as pending before opening the browser, then promotes it to
185+
active only after `authorised` or `review_pending` returns the same key ID.
186+
Cancellation and expiry remove the pending entry; an interrupted CLI can still
187+
recover the key through the key ID printed before the browser opens if the
188+
wallet completed first. The API stores only the public key plus hashes of separate
177189
one-time CLI-polling and browser-approval tokens. The browser token travels in
178190
the URL fragment, not the query string, so it is absent from HTTP logs and
179191
Referer headers; browser reads never return the polling token or resulting

‎services/registry-api/src/index.ts‎

Lines changed: 42 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,14 +2865,16 @@ async function handleCompleteAuthorisationSession(
28652865
request_id: requestId,
28662866
session_id: session.session_id,
28672867
status: session.status,
2868+
...(session.namespace_status ? { namespace_status: session.namespace_status } : {}),
28682869
}, 200, headers);
28692870
}
28702871
if (!session.payload || !session.challenge_token_hash) {
28712872
throw new ApiError(409, "authorisation_challenge_missing", "request a wallet challenge before completing this session");
28722873
}
28732874
const body = await readJson(request, Math.min(maxJsonBytes(env), 128 * 1024));
28742875
const challengeToken = String(body["challenge_token"] ?? "").trim();
2875-
if (!challengeToken || `sha256:${await sha256Hex(challengeToken)}` !== session.challenge_token_hash) {
2876+
const challengeTokenHash = `sha256:${await sha256Hex(challengeToken)}`;
2877+
if (!challengeToken || !await constantTimeSecretEqual(challengeTokenHash, session.challenge_token_hash)) {
28762878
throw new ApiError(401, "invalid_authorisation_challenge_token", "authorisation challenge token is invalid or stale");
28772879
}
28782880
const payload = validateCapabilityPayload(session.payload, registryOrigin, now);
@@ -2884,66 +2886,38 @@ async function handleCompleteAuthorisationSession(
28842886
if (existing && (existing.owner_principal_type !== payload.principal_type || existing.owner_principal_id !== payload.principal_id)) {
28852887
throw new ApiError(409, "namespace_already_claimed", "namespace is already claimed by another principal");
28862888
}
2887-
if (!existing) {
2888-
await enforceNamespaceClaimCooldown(
2889-
store,
2890-
requestId,
2891-
payload.principal_type,
2892-
payload.principal_id,
2893-
now,
2894-
namespaceClaimCooldownSeconds(env),
2895-
);
2896-
}
2897-
const nonceKey = await consumeSignedNonce(store, requestId, {
2889+
const nonce = await signedNonceUse(requestId, {
28982890
protocol: payload.protocol,
28992891
action: `${payload.action}:capability_create`,
29002892
nonce: payload.nonce,
29012893
expires_at: payload.expires_at,
29022894
principal_type: payload.principal_type,
29032895
principal_id: payload.principal_id,
29042896
});
2905-
let capability;
2906-
try {
2907-
capability = await store.recordCapability({ payload, principal_signature: signature, request_id: requestId });
2908-
} catch (error) {
2909-
await store.releaseNonce({ nonce_key: nonceKey, request_id: requestId });
2910-
throw error;
2911-
}
2912-
let namespaceStatus: "active" | "review_pending";
2913-
if (existing) {
2914-
namespaceStatus = existing.status === "active" ? "active" : "review_pending";
2915-
} else {
2916-
const claim = await store.claimNamespace({
2917-
namespace: session.namespace,
2897+
const completion = await store.finaliseAuthorisationSession({
2898+
session_id: sessionId,
2899+
expected_challenge_token_hash: challengeTokenHash,
2900+
payload,
2901+
principal_signature: signature,
2902+
nonce: {
2903+
...nonce,
29182904
principal_type: payload.principal_type,
29192905
principal_id: payload.principal_id,
2920-
request_id: requestId,
2921-
});
2922-
namespaceStatus = claim.status;
2923-
}
2924-
const completed = await store.completeAuthorisationSession({
2925-
session_id: sessionId,
2926-
capability_key_id: capability.key_id,
2927-
namespace_status: namespaceStatus,
2928-
request_id: requestId,
2929-
});
2930-
await store.appendAuditEvent({
2906+
},
29312907
request_id: requestId,
2932-
event_type: "authorisation_session.completed",
2933-
principal_type: payload.principal_type,
2934-
principal_id: payload.principal_id,
2935-
capability_key_id: capability.key_id,
2936-
namespace: session.namespace,
2937-
name: session.name,
2938-
data: { session_id: session.session_id, namespace_status: namespaceStatus },
2908+
now_iso: now.toISOString(),
2909+
namespace_claim_cooldown_seconds: namespaceClaimCooldownSeconds(env),
29392910
});
2911+
const completed = completion.session;
2912+
const namespaceStatus = completed.namespace_status;
2913+
if (!namespaceStatus) throw new Error("completed authorisation session did not record namespace status");
29402914
return json({
29412915
schema: "cellscript-registry-authorisation-session-v1",
29422916
request_id: requestId,
29432917
session_id: completed.session_id,
29442918
status: completed.status,
29452919
namespace_status: namespaceStatus,
2946-
}, namespaceStatus === "active" ? 201 : 202, headers);
2920+
}, completion.replayed ? 200 : namespaceStatus === "active" ? 201 : 202, headers);
29472921
}
29482922

29492923
function validateAuthorisationSessionId(value: string): string {
@@ -3490,19 +3464,17 @@ function idempotencyResponse(record: IdempotencyRecord, headers: Headers): Respo
34903464
return json(record.response_body, record.response_status, replayHeaders);
34913465
}
34923466

3493-
async function consumeSignedNonce(
3494-
store: RegistryStore,
3495-
requestId: string,
3496-
input: {
3497-
protocol: string;
3498-
action: string;
3499-
nonce: string;
3500-
expires_at: string;
3501-
principal_type?: string;
3502-
principal_id?: string;
3503-
capability_key_id?: string;
3504-
},
3505-
): Promise<string> {
3467+
type SignedNonceUseSource = {
3468+
protocol: string;
3469+
action: string;
3470+
nonce: string;
3471+
expires_at: string;
3472+
principal_type?: PrincipalType;
3473+
principal_id?: string;
3474+
capability_key_id?: string;
3475+
};
3476+
3477+
async function signedNonceUse(requestId: string, input: SignedNonceUseSource) {
35063478
const nonceKey = `nonce_${await sha256Hex(canonicalJson({
35073479
protocol: input.protocol,
35083480
action: input.action,
@@ -3511,7 +3483,7 @@ async function consumeSignedNonce(
35113483
principal_id: input.principal_id ?? null,
35123484
capability_key_id: input.capability_key_id ?? null,
35133485
}))}`;
3514-
const accepted = await store.consumeNonce({
3486+
return {
35153487
nonce_key: nonceKey,
35163488
protocol: input.protocol,
35173489
action: input.action,
@@ -3521,7 +3493,16 @@ async function consumeSignedNonce(
35213493
...(input.principal_type ? { principal_type: input.principal_type } : {}),
35223494
...(input.principal_id ? { principal_id: input.principal_id } : {}),
35233495
...(input.capability_key_id ? { capability_key_id: input.capability_key_id } : {}),
3524-
});
3496+
};
3497+
}
3498+
3499+
async function consumeSignedNonce(
3500+
store: RegistryStore,
3501+
requestId: string,
3502+
input: SignedNonceUseSource,
3503+
): Promise<string> {
3504+
const nonceUse = await signedNonceUse(requestId, input);
3505+
const accepted = await store.consumeNonce(nonceUse);
35253506
if (!accepted) {
35263507
await store.appendAuditEvent({
35273508
request_id: requestId,
@@ -3532,12 +3513,12 @@ async function consumeSignedNonce(
35323513
data: {
35333514
protocol: input.protocol,
35343515
action: input.action,
3535-
nonce_key: nonceKey,
3516+
nonce_key: nonceUse.nonce_key,
35363517
},
35373518
});
35383519
throw new ApiError(409, "nonce_replay", "signed nonce has already been used");
35393520
}
3540-
return nonceKey;
3521+
return nonceUse.nonce_key;
35413522
}
35423523

35433524
async function writeStaticRegistryVersionObject(

0 commit comments

Comments
 (0)