cortad 0.3.10 → 0.4.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 +14 -7
- package/lib/auto-reach.mjs +6 -5
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +3 -4
- package/lib/env-files.mjs +39 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/files.mjs +5 -0
- package/lib/fresh.mjs +1 -1
- package/lib/intel-text.mjs +47 -0
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +38 -5
- package/lib/proc.mjs +3 -1
- package/lib/proof.mjs +8 -1
- package/lib/proxy.mjs +14 -3
- package/lib/pyhook/sitecustomize.py +1 -500
- package/lib/reach.mjs +7 -7
- package/lib/read-text.mjs +0 -1
- package/lib/replay.mjs +6 -11
- package/lib/start.mjs +13 -34
- package/lib/switches.mjs +9 -69
- package/lib/trace.cjs +2 -320
- package/lib/verbs.mjs +56 -1
- package/local.mjs +252 -520
- package/package.json +5 -15
- package/skill/SKILL.md +16 -9
- package/skill/references/results.md +5 -5
- package/lib/backing.mjs +0 -230
- package/lib/data.mjs +0 -137
- package/lib/hosted.mjs +0 -201
- package/lib/identities.mjs +0 -689
- package/lib/keeping.mjs +0 -174
- package/lib/ledger.mjs +0 -20
- package/lib/listing.mjs +0 -12
- package/lib/mint.mjs +0 -334
- package/lib/mongo.mjs +0 -74
- package/lib/pg.mjs +0 -118
- package/lib/qdrant.mjs +0 -48
- package/lib/redis.mjs +0 -130
- package/lib/service.mjs +0 -96
- package/lib/stores.mjs +0 -378
package/lib/identities.mjs
DELETED
|
@@ -1,689 +0,0 @@
|
|
|
1
|
-
// The same file lives on the Cortad side. Keep the two in step: the recipes the
|
|
2
|
-
// cloud sends are gated here again, on the machine that holds the secrets.
|
|
3
|
-
// Signing in as the customer's own callers, from inside the box, through their own doors.
|
|
4
|
-
//
|
|
5
|
-
// A recipe (src/setup/road-identities.ts) names env variables, routes and body fields; never a
|
|
6
|
-
// value. This program runs where the app's environment file is, opens the values itself, creates
|
|
7
|
-
// an account through their admin door or exchanges their shared password for a token, and keeps
|
|
8
|
-
// what their door issued in a header file only the box can read. What it prints is roles and
|
|
9
|
-
// statuses: a token the run needs is fetched from the box when a turn first speaks as that role,
|
|
10
|
-
// and a credential that IS a sealed value (an admin key) is marked sealed and never leaves. A
|
|
11
|
-
// caller Firebase verifies is signed up at the Auth emulator the boot raised in place of their
|
|
12
|
-
// project; without that emulator the role stays upstream, verified outside the box.
|
|
13
|
-
|
|
14
|
-
export const IDENTITY_PATH = "/tmp/bl-identities.py";
|
|
15
|
-
export const IDENTITY_OUT = "/tmp/bl-identities.json";
|
|
16
|
-
const HEADER_FILE = "/tmp/bl-identity-%s.%s";
|
|
17
|
-
|
|
18
|
-
const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
|
|
19
|
-
const HEADER = /^[a-z][a-z0-9-]{0,40}$/;
|
|
20
|
-
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
21
|
-
const ROUTE = /^\/[\w./-]{0,200}$/;
|
|
22
|
-
const OPENS = /^\/[\w./{}-]{0,200}$/;
|
|
23
|
-
const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|email|password|name)|role)$/;
|
|
24
|
-
// A Next.js route file is spelled with brackets and route groups with parentheses: `app/api/auth/
|
|
25
|
-
// [...all]/route.ts (better-auth)` failed the old gate and every catch-all auth recipe was dropped
|
|
26
|
-
// before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
|
|
27
|
-
const WHERE = /^[\w./:@()[\] -]{1,300}$/;
|
|
28
|
-
const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
|
|
29
|
-
const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic", "clerk", "supabase-admin"]);
|
|
30
|
-
const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
|
|
31
|
-
|
|
32
|
-
// A credential the app's own environment names for its own account: a user-side name and a
|
|
33
|
-
// password-side name that share a prefix. The prefix is the name with its trailing token removed,
|
|
34
|
-
// so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
|
|
35
|
-
const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
|
|
36
|
-
const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
|
|
37
|
-
// A pair that opens a store or a mail server is never the app's own account: PG_DB_USER with
|
|
38
|
-
// PG_DB_PASSWORD signs in to Postgres, and offered to a sign-in form it sent a database password
|
|
39
|
-
// to the app's login route.
|
|
40
|
-
const SERVICE_PREFIX = /(?:^|_)(?:DB|DATABASE|SQL|PG|PSQL|POSTGRES(?:QL)?|MYSQL|MARIADB|MSSQL|MONGO(?:DB)?|REDIS|VALKEY|ELASTIC(?:SEARCH)?|OPENSEARCH|ES|QDRANT|NEO4J|CLICKHOUSE|RABBIT(?:MQ)?|AMQP|KAFKA|SMTP|MAIL|EMAIL|S3|MINIO|FTP|SFTP|LDAP|PROXY)(?:_|$)/;
|
|
41
|
-
const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
|
|
42
|
-
const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
|
|
43
|
-
const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
|
|
44
|
-
|
|
45
|
-
const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
|
|
46
|
-
export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
|
|
47
|
-
// The curl argument a probe inside the box adds to speak as a role: the header line is read there,
|
|
48
|
-
// so the value never rides in a command this process composes.
|
|
49
|
-
export function identityCurlArg(as) {
|
|
50
|
-
if (as === "browser" || as === "bare" || !ROLE.test(as)) return "";
|
|
51
|
-
return `-H "$(cat ${HEADER_FILE.replace("%s.%s", `${slug(as)}.header`)} ${HEADER_FILE.replace("%s.%s", `${slug(as)}.sealed`)} 2>/dev/null)"`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Every string the recipes carry reaches the program as base64 JSON, never as source, and only
|
|
55
|
-
// after the charset gate: a route or a role is read from a repository.
|
|
56
|
-
export function identityScript(recipes, target, accounts = {}) {
|
|
57
|
-
const safe = recipes.filter(safeRecipe).slice(0, 12);
|
|
58
|
-
const headers = Object.fromEntries(Object.entries(target.headers ?? {}).filter(([k, v]) => HEADER.test(k) && typeof v === "string" && v.length <= 300 && !/[\r\n]/.test(v)));
|
|
59
|
-
const kept = Object.fromEntries(Object.entries(accounts).filter(([role, a]) => ROLE.test(role) && a
|
|
60
|
-
&& ["username", "email", "password", "name"].every((f) => typeof a[f] === "string" && a[f].length <= 120 && !/[\r\n]/.test(a[f]))));
|
|
61
|
-
const plan = { envPath: /^[\w./-]+$/.test(target.envPath) ? target.envPath : "/workspace/repo/.env", base: BASE.test(target.base) ? target.base : "http://127.0.0.1:0", headers, recipes: safe, accounts: kept };
|
|
62
|
-
return PROGRAM
|
|
63
|
-
.replace("__PLAN__", Buffer.from(JSON.stringify(plan), "utf8").toString("base64"))
|
|
64
|
-
.replace("__OUT__", IDENTITY_OUT)
|
|
65
|
-
.replace("__FILE__", HEADER_FILE);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function safeRecipe(r) {
|
|
69
|
-
const step = (s) => s && ROUTE.test(s.route)
|
|
70
|
-
&& Object.entries(s.headers ?? {}).every(([k, v]) => HEADER.test(k) && SOURCE.test(v))
|
|
71
|
-
&& Object.entries(s.body ?? {}).every(([k, v]) => /^[A-Za-z_][\w]{0,40}$/.test(k) && SOURCE.test(v));
|
|
72
|
-
return r && KINDS.has(r.kind) && ROLE.test(r.role) && HEADER.test(r.header) && WHERE.test(r.where)
|
|
73
|
-
&& (r.env ?? []).every((n) => ENV_NAME.test(n))
|
|
74
|
-
&& (r.roles ?? []).every((v) => /^[a-z][a-z0-9_-]{0,30}$/.test(v))
|
|
75
|
-
&& (r.opens ?? []).every((o) => OPENS.test(o))
|
|
76
|
-
&& (r.steps ?? []).every(step) && (r.alt ?? []).every(step)
|
|
77
|
-
&& (r.claim === undefined || /^[a-z_][a-z0-9_]{0,30}$/i.test(r.claim));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const PROGRAM = String.raw`import base64, hashlib, hmac, http.cookiejar, json, os, re, secrets, time, urllib.error, urllib.request
|
|
81
|
-
|
|
82
|
-
PLAN = json.loads(base64.b64decode("__PLAN__").decode("utf-8"))
|
|
83
|
-
OUT = "__OUT__"
|
|
84
|
-
FILE = "__FILE__"
|
|
85
|
-
TTL = 6 * 3600
|
|
86
|
-
# Digits only: an account column is an integer in Strapi, Django and Rails, and a word here makes
|
|
87
|
-
# the database itself raise. Nonerds crashed its whole process on "cortad-test-user". Digits are a
|
|
88
|
-
# valid string id too, so this one value fits both kinds of account table.
|
|
89
|
-
TEST = "9000001"
|
|
90
|
-
# A dev server compiles its auth route on the first request: better-chatbot's sign-in timed out at 10s
|
|
91
|
-
# and the caller was filed unreachable. The whole program stays inside the step's two minutes.
|
|
92
|
-
DEADLINE = time.time() + 100
|
|
93
|
-
SIGNUP = "/identitytoolkit.googleapis.com/v1/accounts:signUp?key=standin"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def read_env(path):
|
|
97
|
-
out = {}
|
|
98
|
-
try:
|
|
99
|
-
with open(path, encoding="utf-8", errors="replace") as f:
|
|
100
|
-
for line in f:
|
|
101
|
-
m = re.match(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", line)
|
|
102
|
-
if not m:
|
|
103
|
-
continue
|
|
104
|
-
v = m.group(2).strip()
|
|
105
|
-
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
|
106
|
-
v = v[1:-1]
|
|
107
|
-
out[m.group(1)] = v
|
|
108
|
-
except OSError:
|
|
109
|
-
pass
|
|
110
|
-
return out
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def slug(role):
|
|
114
|
-
return re.sub(r"[^a-z0-9-]", "-", role)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# One account per role, made once and signed into on every later run: a fresh account per run left
|
|
118
|
-
# 1,600 active test logins in one app's own account store.
|
|
119
|
-
def gen(role):
|
|
120
|
-
if role in PLAN.get("accounts", {}):
|
|
121
|
-
return PLAN["accounts"][role]
|
|
122
|
-
tag = secrets.token_hex(4)
|
|
123
|
-
return {
|
|
124
|
-
"username": "cortad-%s-%s" % (slug(role), tag),
|
|
125
|
-
"email": "cortad-%s-%s@example.invalid" % (slug(role), tag),
|
|
126
|
-
"password": secrets.token_urlsafe(18) + "A1!",
|
|
127
|
-
"name": "Cortad test %s" % role,
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def source(spec, env, g, role):
|
|
132
|
-
if spec.startswith("env:"):
|
|
133
|
-
for name in spec[4:].split("|"):
|
|
134
|
-
if env.get(name):
|
|
135
|
-
return env[name]
|
|
136
|
-
return None
|
|
137
|
-
return {"gen:username": g["username"], "gen:email": g["email"], "gen:password": g["password"], "gen:name": g["name"], "role": role}.get(spec)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def names(spec):
|
|
141
|
-
return spec[4:].replace("|", " or ") if spec.startswith("env:") else spec
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def call(path, headers, body, jar, base=None):
|
|
145
|
-
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
146
|
-
req = urllib.request.Request((base or PLAN["base"]) + path, data=json.dumps(body).encode("utf-8"), method="POST")
|
|
147
|
-
for k, v in list(PLAN["headers"].items()) + list(headers.items()):
|
|
148
|
-
req.add_header(k, v)
|
|
149
|
-
req.add_header("content-type", "application/json")
|
|
150
|
-
try:
|
|
151
|
-
with opener.open(req, timeout=40) as r:
|
|
152
|
-
return r.status, r.read(200000).decode("utf-8", "replace")
|
|
153
|
-
except urllib.error.HTTPError as e:
|
|
154
|
-
# The door's own sentence rides the refusal: a 500 with no body told nobody what the
|
|
155
|
-
# sign-up died on.
|
|
156
|
-
try:
|
|
157
|
-
return e.code, e.read(4000).decode("utf-8", "replace")
|
|
158
|
-
except Exception:
|
|
159
|
-
return e.code, ""
|
|
160
|
-
except Exception as e:
|
|
161
|
-
return 0, type(e).__name__
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def token_in(value, depth=0):
|
|
165
|
-
if isinstance(value, dict):
|
|
166
|
-
for k, v in value.items():
|
|
167
|
-
if isinstance(v, str) and v and re.fullmatch(r"(?i)(access_?token|token|jwt|session_?token|id_?token|api_?key)", k):
|
|
168
|
-
return v
|
|
169
|
-
if depth < 2:
|
|
170
|
-
for v in value.values():
|
|
171
|
-
found = token_in(v, depth + 1)
|
|
172
|
-
if found:
|
|
173
|
-
return found
|
|
174
|
-
return None
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def bearer(header, value):
|
|
178
|
-
if header == "authorization" and not value.lower().startswith("bearer "):
|
|
179
|
-
return "Bearer " + value
|
|
180
|
-
return value
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def minted(header, value, ttl=TTL):
|
|
184
|
-
return {"status": "minted", "header": header, "line": "%s: %s" % (header, bearer(header, value)), "expires": int(time.time()) + ttl}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# The account is named after the identity, not the role slot: staff:sales and sales are two callers
|
|
188
|
-
# at two doors, and their door reads the name back on every login.
|
|
189
|
-
def login(recipe, role, role_value, env):
|
|
190
|
-
g = gen(role)
|
|
191
|
-
ways = [recipe["steps"]] + ([recipe["alt"]] if recipe.get("alt") else [])
|
|
192
|
-
for way in ways:
|
|
193
|
-
last = ways[-1] is way
|
|
194
|
-
got = walk(way, recipe, env, g, role_value)
|
|
195
|
-
if got.get("status") != "refused" or last:
|
|
196
|
-
return got
|
|
197
|
-
return got
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def walk(steps, recipe, env, g, role_value):
|
|
201
|
-
jar = http.cookiejar.CookieJar()
|
|
202
|
-
status, token = 0, None
|
|
203
|
-
for step in steps:
|
|
204
|
-
headers = {}
|
|
205
|
-
# A token the previous step minted signs this one: the key route wants the session that
|
|
206
|
-
# just logged in, and a cookie jar alone does not carry a bearer.
|
|
207
|
-
if token:
|
|
208
|
-
headers[recipe["header"]] = bearer(recipe["header"], token)
|
|
209
|
-
for h, spec in step.get("headers", {}).items():
|
|
210
|
-
v = source(spec, env, g, role_value)
|
|
211
|
-
if v is None:
|
|
212
|
-
return {"status": "absent", "note": names(spec)}
|
|
213
|
-
headers[h] = v
|
|
214
|
-
body = {}
|
|
215
|
-
unset = None
|
|
216
|
-
for field, spec in step["body"].items():
|
|
217
|
-
v = source(spec, env, g, role_value)
|
|
218
|
-
if v is None:
|
|
219
|
-
if not spec.startswith("env:"):
|
|
220
|
-
return {"status": "absent", "note": names(spec)}
|
|
221
|
-
# A switch in their code may make this one optional: ask without it, and a refusal
|
|
222
|
-
# is then the variable they did not seal, not their door.
|
|
223
|
-
unset = names(spec)
|
|
224
|
-
continue
|
|
225
|
-
body[field] = v
|
|
226
|
-
status, text = call(step["route"], headers, body, jar)
|
|
227
|
-
if status == 0:
|
|
228
|
-
return {"status": "unreachable", "note": text}
|
|
229
|
-
if status >= 400 and unset:
|
|
230
|
-
return {"status": "absent", "note": unset}
|
|
231
|
-
# The account this role was given on an earlier run is still there: go on and sign in.
|
|
232
|
-
if status in (400, 409, 422) and step is not steps[-1] and re.search(r"exist|taken|already|duplicate|in use|registered", text or "", re.I):
|
|
233
|
-
continue
|
|
234
|
-
if status >= 400:
|
|
235
|
-
said = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", text or "")).strip()[:160]
|
|
236
|
-
return {"status": "refused", "note": "HTTP %d at %s%s" % (status, step["route"], (": " + said) if said else "")}
|
|
237
|
-
try:
|
|
238
|
-
found = token_in(json.loads(text))
|
|
239
|
-
except ValueError:
|
|
240
|
-
found = None
|
|
241
|
-
# A step that mints nothing keeps what the step before it minted.
|
|
242
|
-
token = found or token
|
|
243
|
-
cookies = "; ".join("%s=%s" % (c.name, c.value) for c in jar)
|
|
244
|
-
# A cookie door reads its cookie by name. better-auth's sign-in answers a token AND sets
|
|
245
|
-
# better-auth.session_token; sending the bare token as the cookie header named nothing, so its
|
|
246
|
-
# proxy redirected every signed-in knock to the sign-in page.
|
|
247
|
-
if recipe["header"] == "cookie" and cookies:
|
|
248
|
-
return minted("cookie", cookies)
|
|
249
|
-
if token:
|
|
250
|
-
return minted(recipe["header"], token)
|
|
251
|
-
if cookies:
|
|
252
|
-
# ponytail: the cookie alone; a CSRF token their form also wants is the next thing to carry.
|
|
253
|
-
return minted("cookie", cookies)
|
|
254
|
-
return {"status": "refused", "note": "HTTP %d with no token in the reply" % status}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def b64url(raw):
|
|
258
|
-
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
def hs256(secret, claims):
|
|
262
|
-
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
|
|
263
|
-
body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
|
264
|
-
sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
|
|
265
|
-
return "%s.%s.%s" % (head, body, sig)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def jwt(recipe, env):
|
|
269
|
-
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
270
|
-
if not secret:
|
|
271
|
-
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
272
|
-
now = int(time.time())
|
|
273
|
-
# On their own machine the database is theirs and live: the token names a test id that belongs
|
|
274
|
-
# to nobody, never account 1, which is usually the person who built the app. (The sandbox copy
|
|
275
|
-
# of this program signs account 1 of a restored copy; this one differs on purpose.)
|
|
276
|
-
claims = {"sub": TEST, "id": TEST, "iat": now, "exp": now + TTL}
|
|
277
|
-
claim = recipe.get("claim") or "sub"
|
|
278
|
-
if claim not in claims:
|
|
279
|
-
claims[claim] = TEST
|
|
280
|
-
return minted("authorization", hs256(secret, claims))
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
# GoTrue signs its access token HS256 with the project's JWT secret; RLS reads sub as auth.uid()
|
|
284
|
-
# and role as auth.role(). The id is a well-formed uuid that belongs to nobody, never a real row.
|
|
285
|
-
SUPA_UID = "00000000-0000-4000-8000-000000009001"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def supabase(recipe, env):
|
|
289
|
-
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
290
|
-
if not secret:
|
|
291
|
-
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
292
|
-
now = int(time.time())
|
|
293
|
-
claims = {"sub": SUPA_UID, "aud": "authenticated", "role": "authenticated", "iat": now, "exp": now + TTL, "is_anonymous": False}
|
|
294
|
-
return minted("authorization", hs256(secret, claims))
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
# Auth.js v5 (and next-auth v5) seals its session as a JWE, not a signed JWT: dir key management,
|
|
298
|
-
# A256CBC-HS512 content encryption, the key derived from the secret with HKDF over the cookie name.
|
|
299
|
-
# Verified against the @auth/core jwt.ts encode path. next-auth v4 (A256GCM) is not minted here.
|
|
300
|
-
def hkdf_sha256(secret, salt, info, length):
|
|
301
|
-
salt = salt or bytes(hashlib.sha256().digest_size)
|
|
302
|
-
prk = hmac.new(salt, secret, hashlib.sha256).digest()
|
|
303
|
-
out, block, i = b"", b"", 1
|
|
304
|
-
while len(out) < length:
|
|
305
|
-
block = hmac.new(prk, block + info + bytes([i]), hashlib.sha256).digest()
|
|
306
|
-
out += block
|
|
307
|
-
i += 1
|
|
308
|
-
return out[:length]
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
AES_SBOX = bytes.fromhex(
|
|
312
|
-
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
|
|
313
|
-
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
|
|
314
|
-
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
|
|
315
|
-
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
|
|
316
|
-
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
|
|
317
|
-
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
|
|
318
|
-
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
|
|
319
|
-
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")
|
|
320
|
-
AES_RCON = (0, 1, 2, 4, 8, 16, 32, 64)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def _aes_mul(a, b):
|
|
324
|
-
r = 0
|
|
325
|
-
for _ in range(8):
|
|
326
|
-
if b & 1:
|
|
327
|
-
r ^= a
|
|
328
|
-
hi = a & 0x80
|
|
329
|
-
a = (a << 1) & 0xFF
|
|
330
|
-
if hi:
|
|
331
|
-
a ^= 0x1B
|
|
332
|
-
b >>= 1
|
|
333
|
-
return r
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _aes_expand(key):
|
|
337
|
-
w = [list(key[i:i + 4]) for i in range(0, 32, 4)]
|
|
338
|
-
for i in range(8, 60):
|
|
339
|
-
t = list(w[i - 1])
|
|
340
|
-
if i % 8 == 0:
|
|
341
|
-
t = [AES_SBOX[b] for b in t[1:] + t[:1]]
|
|
342
|
-
t[0] ^= AES_RCON[i // 8]
|
|
343
|
-
elif i % 8 == 4:
|
|
344
|
-
t = [AES_SBOX[b] for b in t]
|
|
345
|
-
w.append([w[i - 8][j] ^ t[j] for j in range(4)])
|
|
346
|
-
return [b for word in w for b in word]
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _aes_shift_rows(s):
|
|
350
|
-
o = list(s)
|
|
351
|
-
for r in range(1, 4):
|
|
352
|
-
vals = [s[c * 4 + r] for c in range(4)]
|
|
353
|
-
vals = vals[r:] + vals[:r]
|
|
354
|
-
for c in range(4):
|
|
355
|
-
o[c * 4 + r] = vals[c]
|
|
356
|
-
return o
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _aes_encrypt_block(block, rk):
|
|
360
|
-
s = list(block)
|
|
361
|
-
|
|
362
|
-
def add(rnd):
|
|
363
|
-
for i in range(16):
|
|
364
|
-
s[i] ^= rk[rnd * 16 + i]
|
|
365
|
-
|
|
366
|
-
add(0)
|
|
367
|
-
for rnd in range(1, 14):
|
|
368
|
-
s = [AES_SBOX[b] for b in s]
|
|
369
|
-
s = _aes_shift_rows(s)
|
|
370
|
-
mixed = []
|
|
371
|
-
for c in range(4):
|
|
372
|
-
a, b, d, e = s[c * 4:c * 4 + 4]
|
|
373
|
-
mixed += [
|
|
374
|
-
_aes_mul(a, 2) ^ _aes_mul(b, 3) ^ d ^ e,
|
|
375
|
-
a ^ _aes_mul(b, 2) ^ _aes_mul(d, 3) ^ e,
|
|
376
|
-
a ^ b ^ _aes_mul(d, 2) ^ _aes_mul(e, 3),
|
|
377
|
-
_aes_mul(a, 3) ^ b ^ d ^ _aes_mul(e, 2),
|
|
378
|
-
]
|
|
379
|
-
s = mixed
|
|
380
|
-
add(rnd)
|
|
381
|
-
s = _aes_shift_rows([AES_SBOX[b] for b in s])
|
|
382
|
-
add(14)
|
|
383
|
-
return bytes(s)
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def _aes_cbc(key, iv, data):
|
|
387
|
-
rk = _aes_expand(key)
|
|
388
|
-
out, prev = b"", iv
|
|
389
|
-
for i in range(0, len(data), 16):
|
|
390
|
-
block = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
|
|
391
|
-
prev = _aes_encrypt_block(block, rk)
|
|
392
|
-
out += prev
|
|
393
|
-
return out
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
def _authjs_cookie(env_path):
|
|
397
|
-
root = os.path.dirname(os.path.abspath(env_path))
|
|
398
|
-
for _ in range(5):
|
|
399
|
-
nm = os.path.join(root, "node_modules")
|
|
400
|
-
if os.path.isdir(os.path.join(nm, "@auth", "core")):
|
|
401
|
-
return "authjs.session-token"
|
|
402
|
-
try:
|
|
403
|
-
with open(os.path.join(nm, "next-auth", "package.json"), encoding="utf-8") as f:
|
|
404
|
-
major = json.load(f).get("version", "").lstrip("^~").split(".")[0]
|
|
405
|
-
return "authjs.session-token" if major.isdigit() and int(major) >= 5 else None
|
|
406
|
-
except OSError:
|
|
407
|
-
pass
|
|
408
|
-
parent = os.path.dirname(root)
|
|
409
|
-
if parent == root:
|
|
410
|
-
break
|
|
411
|
-
root = parent
|
|
412
|
-
# The dependency was detected upstream but the tree is not on disk: v5 is the current default.
|
|
413
|
-
return "authjs.session-token"
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def authjs(recipe, env):
|
|
417
|
-
secret = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
418
|
-
if not secret:
|
|
419
|
-
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
420
|
-
cookie = _authjs_cookie(PLAN["envPath"])
|
|
421
|
-
if not cookie:
|
|
422
|
-
return {"status": "upstream", "note": "Auth.js v4"}
|
|
423
|
-
key = hkdf_sha256(secret.encode("utf-8"), cookie.encode("utf-8"), ("Auth.js Generated Encryption Key (%s)" % cookie).encode("utf-8"), 64)
|
|
424
|
-
mac_key, enc_key = key[:32], key[32:]
|
|
425
|
-
now = int(time.time())
|
|
426
|
-
claims = {"sub": TEST, "name": "Cortad test", "email": "cortad@example.invalid", "iat": now, "exp": now + TTL, "jti": secrets.token_hex(16)}
|
|
427
|
-
header = b64url(json.dumps({"alg": "dir", "enc": "A256CBC-HS512"}, separators=(",", ":")).encode("utf-8"))
|
|
428
|
-
plaintext = json.dumps(claims, separators=(",", ":")).encode("utf-8")
|
|
429
|
-
pad = 16 - (len(plaintext) % 16)
|
|
430
|
-
iv = secrets.token_bytes(16)
|
|
431
|
-
ct = _aes_cbc(enc_key, iv, plaintext + bytes([pad]) * pad)
|
|
432
|
-
aad = header.encode("ascii")
|
|
433
|
-
al = (len(aad) * 8).to_bytes(8, "big")
|
|
434
|
-
tag = hmac.new(mac_key, aad + iv + ct + al, hashlib.sha512).digest()[:32]
|
|
435
|
-
jwe = "%s..%s.%s.%s" % (header, b64url(iv), b64url(ct), b64url(tag))
|
|
436
|
-
return minted("cookie", "%s=%s" % (cookie, jwe))
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
# The Auth emulator the boot raised in place of their Firebase project: it signs any address up
|
|
440
|
-
# under any key, and firebase-admin accepts its tokens while FIREBASE_AUTH_EMULATOR_HOST is set.
|
|
441
|
-
def firebase(recipe, role, env):
|
|
442
|
-
host = env.get("FIREBASE_AUTH_EMULATOR_HOST", "")
|
|
443
|
-
if not re.fullmatch(r"[\w.-]+:\d{2,5}", host):
|
|
444
|
-
return {"status": "upstream", "note": "Firebase"}
|
|
445
|
-
g = gen(role)
|
|
446
|
-
status, text = call(SIGNUP, {}, {"email": g["email"], "password": g["password"], "returnSecureToken": True}, http.cookiejar.CookieJar(), "http://" + host)
|
|
447
|
-
if status == 0:
|
|
448
|
-
return {"status": "unreachable", "note": text}
|
|
449
|
-
try:
|
|
450
|
-
reply = json.loads(text)
|
|
451
|
-
except ValueError:
|
|
452
|
-
reply = {}
|
|
453
|
-
token = reply.get("idToken") if isinstance(reply, dict) else None
|
|
454
|
-
if not isinstance(token, str) or not token:
|
|
455
|
-
return {"status": "refused", "note": "HTTP %d at the Firebase emulator" % status}
|
|
456
|
-
return minted(recipe["header"], token, int(reply.get("expiresIn") or 3600))
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def sealed(recipe, env):
|
|
460
|
-
v = source("env:" + "|".join(recipe["env"]), env, None, None)
|
|
461
|
-
if not v:
|
|
462
|
-
return {"status": "absent", "note": " or ".join(recipe["env"])}
|
|
463
|
-
return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
# HTTP Basic from the username and password the app's own environment names: env[0] is the user,
|
|
467
|
-
# env[1] the password. The line carries the scheme verbatim, so it is not run through bearer().
|
|
468
|
-
def basic(recipe, env):
|
|
469
|
-
names = recipe.get("env", [])
|
|
470
|
-
user = env.get(names[0]) if len(names) > 0 else None
|
|
471
|
-
pw = env.get(names[1]) if len(names) > 1 else None
|
|
472
|
-
if not user or not pw:
|
|
473
|
-
return {"status": "absent", "note": " and ".join(names)}
|
|
474
|
-
raw = base64.b64encode(("%s:%s" % (user, pw)).encode("utf-8")).decode("ascii")
|
|
475
|
-
return {"status": "minted", "header": recipe["header"], "line": "%s: Basic %s" % (recipe["header"], raw), "expires": int(time.time()) + TTL, "note": " and ".join(names)}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def keep(path, line):
|
|
479
|
-
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
480
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
481
|
-
f.write(line + "\n")
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
# A token their door issued earlier and still honours is reused: sign-in doors are rate limited,
|
|
485
|
-
# and a run re-minting on every boot would spend that budget on itself.
|
|
486
|
-
def previous():
|
|
487
|
-
try:
|
|
488
|
-
with open(OUT, encoding="utf-8") as f:
|
|
489
|
-
rows = json.load(f)["identities"]
|
|
490
|
-
except Exception:
|
|
491
|
-
return {}
|
|
492
|
-
now = time.time()
|
|
493
|
-
out = {}
|
|
494
|
-
for r in rows:
|
|
495
|
-
if r.get("status") not in ("minted", "sealed"):
|
|
496
|
-
continue
|
|
497
|
-
if not os.path.exists(FILE % (slug(r["role"]), "header" if r["status"] == "minted" else "sealed")):
|
|
498
|
-
continue
|
|
499
|
-
if r.get("expires") is not None and r["expires"] < now + 60:
|
|
500
|
-
continue
|
|
501
|
-
out[r["role"]] = r
|
|
502
|
-
return out
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def main():
|
|
506
|
-
env = read_env(PLAN["envPath"])
|
|
507
|
-
kept = previous()
|
|
508
|
-
rows = []
|
|
509
|
-
for recipe in PLAN["recipes"]:
|
|
510
|
-
for role_value in recipe.get("roles") or [None]:
|
|
511
|
-
role = "%s:%s" % (recipe["role"], role_value) if role_value else recipe["role"]
|
|
512
|
-
steps = recipe.get("steps") or []
|
|
513
|
-
base = {
|
|
514
|
-
"role": role, "kind": recipe["kind"], "header": recipe["header"], "where": recipe["where"],
|
|
515
|
-
"opens": [o.replace("{role}", role_value or "") for o in recipe.get("opens", [])],
|
|
516
|
-
"door": steps[-1]["route"] if steps else SIGNUP if recipe["kind"] == "firebase" else recipe["header"],
|
|
517
|
-
}
|
|
518
|
-
if role in kept:
|
|
519
|
-
rows.append(dict(base, **{k: v for k, v in kept[role].items() if k in ("status", "header", "expires", "note")}))
|
|
520
|
-
continue
|
|
521
|
-
if time.time() > DEADLINE:
|
|
522
|
-
rows.append(dict(base, status="unreachable", note="out of time"))
|
|
523
|
-
continue
|
|
524
|
-
try:
|
|
525
|
-
if recipe["kind"] == "header":
|
|
526
|
-
got = sealed(recipe, env)
|
|
527
|
-
elif recipe["kind"] == "jwt":
|
|
528
|
-
got = jwt(recipe, env)
|
|
529
|
-
elif recipe["kind"] == "supabase":
|
|
530
|
-
got = supabase(recipe, env)
|
|
531
|
-
elif recipe["kind"] == "authjs":
|
|
532
|
-
got = authjs(recipe, env)
|
|
533
|
-
elif recipe["kind"] == "login":
|
|
534
|
-
got = login(recipe, role, role_value, env)
|
|
535
|
-
elif recipe["kind"] == "firebase":
|
|
536
|
-
got = firebase(recipe, role, env)
|
|
537
|
-
elif recipe["kind"] == "basic":
|
|
538
|
-
got = basic(recipe, env)
|
|
539
|
-
else:
|
|
540
|
-
got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
|
|
541
|
-
except Exception as err:
|
|
542
|
-
got = {"status": "unreachable", "note": type(err).__name__}
|
|
543
|
-
# A sign-in the environment named its own credential for records which names it used.
|
|
544
|
-
if recipe.get("envCred") and got.get("status") == "minted" and not got.get("note"):
|
|
545
|
-
got["note"] = " and ".join(recipe.get("env", []))
|
|
546
|
-
line = got.pop("line", None)
|
|
547
|
-
if line:
|
|
548
|
-
keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
|
|
549
|
-
rows.append(dict(base, **got))
|
|
550
|
-
report = {"identities": rows}
|
|
551
|
-
with open(OUT, "w", encoding="utf-8") as f:
|
|
552
|
-
json.dump(report, f)
|
|
553
|
-
print("BL_IDENTITIES " + json.dumps(report))
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
main()
|
|
557
|
-
`;
|
|
558
|
-
|
|
559
|
-
// The one line the program wrote, as typed rows. Anything malformed is dropped rather than read,
|
|
560
|
-
// and nothing here is a value: a role, a header name, a status, a door.
|
|
561
|
-
export function parseIdentityReport(text) {
|
|
562
|
-
const line = String(text ?? "").split("\n").find((l) => l.startsWith("BL_IDENTITIES {")) ?? (String(text ?? "").trim().startsWith("{") ? `BL_IDENTITIES ${String(text).trim()}` : null);
|
|
563
|
-
if (!line) return null;
|
|
564
|
-
let raw;
|
|
565
|
-
try {
|
|
566
|
-
raw = JSON.parse(line.slice("BL_IDENTITIES ".length));
|
|
567
|
-
} catch {
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
if (!Array.isArray(raw?.identities)) return null;
|
|
571
|
-
const identities = raw.identities.flatMap((r) => {
|
|
572
|
-
if (!r || !ROLE.test(String(r.role)) || !KINDS.has(r.kind) || !HEADER.test(String(r.header)) || !STATUSES.has(r.status)) return [];
|
|
573
|
-
if (typeof r.where !== "string" || !WHERE.test(r.where) || typeof r.door !== "string" || r.door.length > 200) return [];
|
|
574
|
-
const opens = (Array.isArray(r.opens) ? r.opens : []).filter((o) => typeof o === "string" && OPENS.test(o)).slice(0, 8);
|
|
575
|
-
return [{
|
|
576
|
-
role: r.role, kind: r.kind, header: r.header, status: r.status, opens, door: r.door, where: r.where,
|
|
577
|
-
...(Number.isFinite(r.expires) ? { expires: Number(r.expires) } : {}),
|
|
578
|
-
...(typeof r.note === "string" ? { note: r.note.slice(0, 200) } : {}),
|
|
579
|
-
}];
|
|
580
|
-
});
|
|
581
|
-
return { identities };
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// The credential pairs an environment names, by shared prefix. Names only, never values.
|
|
585
|
-
export function credentialPairs(names) {
|
|
586
|
-
const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === "string" && ENV_NAME.test(n));
|
|
587
|
-
const users = list.filter((n) => USER_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(USER_TOKEN, "") }));
|
|
588
|
-
const secrets = list.filter((n) => SECRET_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(SECRET_TOKEN, "") }));
|
|
589
|
-
const pairs = [];
|
|
590
|
-
const seen = new Set();
|
|
591
|
-
for (const u of users) {
|
|
592
|
-
const s = secrets.find((x) => x.prefix === u.prefix);
|
|
593
|
-
if (s && !seen.has(u.prefix) && !SERVICE_PREFIX.test(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
|
|
594
|
-
}
|
|
595
|
-
return pairs;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const routeRole = (route) => {
|
|
599
|
-
const seg = route.split("/").filter((s) => s && !/^(?:api|v\d+|auth)$/i.test(s)).pop() ?? "user";
|
|
600
|
-
const r = seg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
|
|
601
|
-
return /^[a-z]/.test(r) ? r : "user";
|
|
602
|
-
};
|
|
603
|
-
const uniqueRole = (base, used) => {
|
|
604
|
-
const stem = /^[a-z]/.test(base) ? base : "account";
|
|
605
|
-
let role = stem;
|
|
606
|
-
for (let n = 2; used.has(role); n += 1) role = `${stem}-${n}`;
|
|
607
|
-
used.add(role);
|
|
608
|
-
return role;
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
// When the app's own environment names a credential pair, the box can sign in with it. Each login
|
|
612
|
-
// recipe whose sign-in form takes a username or email and a password is filled from the pair
|
|
613
|
-
// instead of a generated account; each discovered sign-in route with no recipe becomes one; and an
|
|
614
|
-
// HTTP Basic identity is minted for a door that answers 401 with a Basic challenge. Values are
|
|
615
|
-
// opened by the mint program on the machine, never here: this only rewrites which env name a field
|
|
616
|
-
// reads. A register step (one of several, not a sign-in) keeps its fresh account.
|
|
617
|
-
export function envCredentialRecipes(recipes, names, loginRoutes = []) {
|
|
618
|
-
const list = Array.isArray(recipes) ? recipes : [];
|
|
619
|
-
const pairs = credentialPairs(names);
|
|
620
|
-
if (!pairs.length) return list;
|
|
621
|
-
const pair = pairs[0];
|
|
622
|
-
const userSrc = `env:${pair.user}`;
|
|
623
|
-
const passSrc = `env:${pair.secret}`;
|
|
624
|
-
const used = new Set(list.map((r) => r?.role).filter(Boolean));
|
|
625
|
-
const covered = new Set();
|
|
626
|
-
const out = list.map((r) => {
|
|
627
|
-
if (r?.kind !== "login" || !Array.isArray(r.steps)) return r;
|
|
628
|
-
let touched = false;
|
|
629
|
-
const steps = r.steps.map((s) => {
|
|
630
|
-
if (r.steps.length !== 1 && !SIGN_IN_ROUTE.test(s.route)) return s;
|
|
631
|
-
const body = { ...(s.body ?? {}) };
|
|
632
|
-
let hit = false;
|
|
633
|
-
for (const [f, v] of Object.entries(body)) {
|
|
634
|
-
if (typeof v !== "string" || !v.startsWith("gen:")) continue;
|
|
635
|
-
if (CRED_USER_FIELD.test(f)) { body[f] = userSrc; hit = true; }
|
|
636
|
-
else if (CRED_PASS_FIELD.test(f)) { body[f] = passSrc; hit = true; }
|
|
637
|
-
}
|
|
638
|
-
if (!hit) return s;
|
|
639
|
-
covered.add(s.route);
|
|
640
|
-
touched = true;
|
|
641
|
-
return { ...s, body };
|
|
642
|
-
});
|
|
643
|
-
return touched ? { ...r, steps, env: [...new Set([...(r.env ?? []), pair.user, pair.secret])], envCred: true } : r;
|
|
644
|
-
});
|
|
645
|
-
const known = new Set(out.flatMap((r) => (r?.steps ?? []).map((s) => s.route)));
|
|
646
|
-
const userField = /_EMAIL$/.test(pair.user) ? "email" : "username";
|
|
647
|
-
for (const route of (Array.isArray(loginRoutes) ? loginRoutes : []).slice(0, 6)) {
|
|
648
|
-
if (typeof route !== "string" || !route.startsWith("/") || known.has(route) || covered.has(route)) continue;
|
|
649
|
-
out.push({
|
|
650
|
-
kind: "login", role: uniqueRole(routeRole(route), used), header: "authorization", env: [pair.user, pair.secret],
|
|
651
|
-
steps: [{ route, body: { [userField]: userSrc, password: passSrc } }], opens: [], envCred: true,
|
|
652
|
-
where: `your environment (${pair.user} and ${pair.secret})`,
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
// A Basic identity for each pair as well: a door that wants a sign-in form ignores it, and a door
|
|
656
|
-
// behind HTTP Basic (which declares no route to fill) is reached by nothing else.
|
|
657
|
-
for (const p of pairs) {
|
|
658
|
-
out.push({
|
|
659
|
-
kind: "basic", role: uniqueRole(slug(p.prefix.toLowerCase()) || "account", used), header: "authorization",
|
|
660
|
-
env: [p.user, p.secret], opens: [], envCred: true, where: `your environment (${p.user} and ${p.secret})`,
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
return out;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// What each row means to the person whose app this is.
|
|
667
|
-
export function identitySentence(row) {
|
|
668
|
-
switch (row.status) {
|
|
669
|
-
case "minted": return row.kind === "firebase"
|
|
670
|
-
? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
|
|
671
|
-
: row.kind === "basic"
|
|
672
|
-
? `signed in as ${row.role} with the username and password your environment sets (${row.note ?? "two variables"}); the header stays inside the sandbox`
|
|
673
|
-
: `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
|
|
674
|
-
case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
|
|
675
|
-
case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
|
|
676
|
-
case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
|
|
677
|
-
case "unreachable": return `the ${row.role} sign-in at ${row.door} did not answer (${row.note}); that is ours to look at`;
|
|
678
|
-
default: return `${row.role} callers are verified by ${row.note}, outside this sandbox; their doors are counted and not driven`;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Write the program, run it detached, read the report back, and leave only the header files and
|
|
683
|
-
// the report the next mint reuses.
|
|
684
|
-
export async function mintInBox(box, recipes, target) {
|
|
685
|
-
if (!(await box.write(IDENTITY_PATH, identityScript(recipes, target)))) return null;
|
|
686
|
-
await box.run("bl-identities", `python3 ${IDENTITY_PATH}`).catch(() => null);
|
|
687
|
-
const out = await box.exec(`cat ${IDENTITY_OUT} 2>/dev/null; rm -f ${IDENTITY_PATH}`).catch(() => "");
|
|
688
|
-
return parseIdentityReport(out);
|
|
689
|
-
}
|