fix(mosaic): make credential lifecycle transactional

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent fa301afb8c
commit 791c57157a
13 changed files with 389 additions and 43 deletions
+1 -1
View File
@@ -451,7 +451,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither, and SHALL read the provider `/user` object back through each path. A wrong-host or absent Tea login SHALL never fall back to a host default.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
@@ -516,6 +516,11 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
return 1
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
@@ -32,6 +32,12 @@ done
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
exit 1
fi
if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
@@ -103,6 +103,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
@@ -131,7 +140,7 @@ assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^u
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr")
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-missing.stderr")
@@ -111,6 +111,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
+43 -7
View File
@@ -1,5 +1,5 @@
import { fstatSync, writeSync } from 'node:fs';
import { open, readFile, rename } from 'node:fs/promises';
import { open, rename } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
@@ -486,7 +486,7 @@ export async function executeCredentialRotate(
});
}
const context = await lifecycleContext(options);
const old = await context.store.readBinding(identity, options.estate, options.host);
const old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
@@ -510,16 +510,17 @@ export async function executeCredentialRotate(
estate: options.estate,
host: options.host,
tokenName: options.tokenName,
scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean),
scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean),
},
authority,
context.provider,
context.store,
context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor },
{ stateRoot: context.stateRoot, actor: options.actor, allowReplace: true },
);
if (provisioned.outcome !== 'ok') {
await journal.seal(provisioned.outcome, provisioned.reason.code);
old.secret.fill(0);
return {
...provisioned,
operation: 'rotate',
@@ -527,9 +528,32 @@ export async function executeCredentialRotate(
};
}
await journal.recordMutation('token-mint-applied');
await context.provider.revokeToken(authority, identity, old.tokenName);
await journal.recordMutation('token-revoke-applied');
try {
await context.provider.revokeToken(authority, identity, old.binding.tokenName);
if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) {
throw new Error('old token still exists');
}
await journal.recordMutation('token-revoke-applied');
} catch {
await context.provider.revokeToken(authority, identity, options.tokenName);
if (await context.provider.tokenExists(authority, identity, options.tokenName)) {
throw new Error('replacement rollback could not be verified');
}
await context.store.put(old.binding, old.secret);
await context.teaStore.put(identity, options.host, old.secret);
await journal.recordMutation('rotate-rollback-verified');
await journal.seal('indeterminate', 'old-credential-preserved');
old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, {
outcome: 'indeterminate',
mutation: 'none',
code: 'old-credential-preserved',
message: 'Replacement failed; the previous credential remains the canonical binding.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
await journal.seal('ok', 'rotate-verified');
old.secret.fill(0);
return {
...provisioned,
operation: 'rotate',
@@ -571,7 +595,19 @@ export async function executeCredentialWire(
});
await journal.recordIntent('wire-requested');
try {
const existing = await readFile(options.seatEnv, 'utf8').catch((): string => '');
let existing = '';
try {
const snapshot = readRegularFileSecure(options.seatEnv, {
root: dirname(options.seatEnv),
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new Error('seat environment ownership or mode is unsafe');
}
existing = snapshot.content.toString('utf8');
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
}
const lines = existing
.split(/\r?\n/)
.filter(
@@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set<string>([
'token-mint-applied',
'token-binding-stored',
'tea-login-stored',
'provision-rollback-verified',
'rotate-rollback-verified',
'token-revoke-applied',
'wire-applied',
'credential-issued',
@@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto {
readonly tokenName: string;
readonly scopes: readonly string[];
readonly createdAt: string;
readonly tokenDigest?: string;
}
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
@@ -24,6 +24,10 @@ const bindingSchema = z
tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(),
tokenDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
})
.strict();
@@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver {
throw error;
}
const bindingPath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
);
try {
const bindingSnapshot = readRegularFileSecure(bindingPath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
const digest = createHash('sha256').update(snapshot.content).digest('hex');
if (!binding.success || binding.data.tokenDigest !== digest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'token and binding metadata are not one committed generation',
);
}
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
// Legacy token files predate binding metadata and remain readable until rotated.
}
const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) {
throw new CredentialStoreError(
@@ -180,10 +206,24 @@ export class FileCredentialStore {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 });
const binding = bindingSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
});
const suffix = randomUUID();
const tokenTemp = `${paths.token}.${suffix}.tmp`;
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch {
throw new CredentialStoreError(
'conflicting-credential-mutation',
'another mutation owns the identity lock',
);
}
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
try {
@@ -195,9 +235,41 @@ export class FileCredentialStore {
await tokenHandle.close();
await bindingHandle.close();
}
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory);
try {
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
async snapshot(
identity: string,
estate: string,
host: string,
): Promise<
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
}
| undefined
> {
const binding = await this.readBinding(identity, estate, host);
if (binding === undefined) return undefined;
const resolved = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (resolved === undefined) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
}
async readBinding(
@@ -812,6 +812,39 @@ export class GiteaLifecycleProviderAdapter
};
}
async tokenExists(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<boolean> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
'token absence read-back failed',
);
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'token absence read-back was malformed',
);
}
return parsed.data.some((token): boolean => token.name === name);
}
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
@@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider {
};
},
async revokeToken(): Promise<void> {},
async tokenExists(): Promise<boolean> {
return false;
},
};
}
@@ -108,6 +111,42 @@ describe('credential lifecycle', (): void => {
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
});
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
name,
scopes: ['admin'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
});
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-bad',
scopes: ['write:repository'],
},
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
@@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => {
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const lifecycleProvider = provider();
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('indeterminate');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
});
});
+73 -28
View File
@@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider {
name: string,
): Promise<TokenObjectEvidenceDto>;
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
}
export interface LifecycleRequest {
@@ -48,6 +49,7 @@ export interface LifecycleOptions {
readonly stateRoot: string;
readonly actor: string;
readonly now?: () => string;
readonly allowReplace?: boolean;
}
function lifecycleResult(
@@ -114,6 +116,19 @@ export async function provisionCredential(
const journal = await openLifecycleJournal('provision', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
@@ -146,17 +161,8 @@ export async function provisionCredential(
const expected = [...request.scopes].sort();
const actual = [...readBack.scopes].sort();
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
await journal.seal('indeterminate', 'scope-not-evaluable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'scope-not-evaluable',
message: 'Minted token scope read-back disagreed with the request.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
failureCode = 'scope-not-evaluable';
throw new Error('scope read-back disagreed');
}
await store.put(
{
@@ -173,18 +179,12 @@ export async function provisionCredential(
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (teaLogin === undefined) {
await journal.seal('indeterminate', 'tea-login-missing');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation: 'applied',
code: 'tea-login-missing',
message: 'Token was stored but the host-bound Tea login did not read back.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
if (
teaLogin === undefined ||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
) {
failureCode = 'tea-login-missing';
throw new Error('Tea login did not resolve exactly');
}
await journal.recordMutation('tea-login-stored');
await journal.recordProviderEvidence({
@@ -206,18 +206,52 @@ export async function provisionCredential(
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error;
const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown';
await journal.seal('indeterminate', code);
if (minted === undefined) {
await journal.seal('indeterminate', 'provider-unavailable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'provider-unavailable',
message: 'Provider token mint did not complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
let rollbackComplete = false;
try {
await provider.revokeToken(authority, request.identity, request.tokenName);
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
}
rollbackComplete = true;
await journal.recordMutation('provision-rollback-verified');
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
}
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
outcome: rollbackComplete ? 'error' : 'indeterminate',
mutation: rollbackComplete ? 'none' : mutation,
code,
message: 'Provisioning did not produce complete provider and storage evidence.',
message: rollbackComplete
? 'Provisioning failed and every completed mutation was rolled back.'
: 'Provisioning failed and rollback could not be proven complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
minted?.secret.fill(0);
prior?.secret.fill(0);
}
}
@@ -247,6 +281,17 @@ export async function revokeCredential(
await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied';
await journal.recordMutation('token-revoke-applied');
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
await journal.seal('indeterminate', 'revoke-readback-missing');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'revoke-readback-missing',
message: 'Provider still returned the token after revocation acknowledgement.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
await store.remove(request.identity, request.estate, request.host);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
@@ -1,9 +1,10 @@
import { randomUUID } from 'node:crypto';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -91,6 +92,64 @@ export class TeaLoginStore {
await rename(temp, this.configPath);
}
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter(
(login): boolean =>
login.name === identity && login.url === `https://${host}` && login.user === identity,
);
if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(matches[0].token),
});
}
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
const resolved = this.resolve(identity, 'binding-check', host);
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
const directory = dirname(this.configPath);
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
const logins = decoded.data.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
}
readBack(
identity: string,
host: string,