fix(mosaic): bind lifecycle across storage and runtime
This commit is contained in:
@@ -529,7 +529,29 @@ get_gitea_token() {
|
|||||||
esac
|
esac
|
||||||
if [[ -n "$_idpfx" ]]; then
|
if [[ -n "$_idpfx" ]]; then
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
|
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||||
|
if [[ -r "$_idcred" ]]; then
|
||||||
|
local _resolved_token
|
||||||
|
_resolved_token=$(python3 - "$_idcred" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
||||||
|
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(value)
|
||||||
|
PY
|
||||||
|
) || return 1
|
||||||
|
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
|
||||||
|
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
|
||||||
|
"$_ident" "$host" "$_ident_src" >&2
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$_resolved_token"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
|
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
|
||||||
|
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
|
||||||
|
"$_ident" "$host" "$_ident_src" >&2
|
||||||
|
fi
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -46,7 +46,29 @@ if [ -n "$ident" ]; then
|
|||||||
esac
|
esac
|
||||||
if [ -n "$idpfx" ]; then
|
if [ -n "$idpfx" ]; then
|
||||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
|
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||||
|
if [ -r "$idcred" ]; then
|
||||||
|
token=$(python3 - "$idcred" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
||||||
|
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(value)
|
||||||
|
PY
|
||||||
|
) || exit 1
|
||||||
|
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
|
||||||
|
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||||
|
"$ident" "$host" >&2
|
||||||
|
fi
|
||||||
|
echo "username=${ident}"
|
||||||
|
echo "password=${token}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [ -r "$idtok" ]; then
|
if [ -r "$idtok" ]; then
|
||||||
|
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
|
||||||
|
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
||||||
|
"$ident" "$host" >&2
|
||||||
|
fi
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=$(cat "$idtok")"
|
echo "password=$(cat "$idtok")"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -462,6 +462,7 @@ export async function executeCredentialRevoke(
|
|||||||
authority,
|
authority,
|
||||||
context.provider,
|
context.provider,
|
||||||
context.store,
|
context.store,
|
||||||
|
context.teaStore,
|
||||||
{ stateRoot: context.stateRoot, actor: options.actor },
|
{ stateRoot: context.stateRoot, actor: options.actor },
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -660,6 +661,19 @@ export async function executeCredentialGet(
|
|||||||
});
|
});
|
||||||
await journal.recordIntent('get-requested');
|
await journal.recordIntent('get-requested');
|
||||||
try {
|
try {
|
||||||
|
if (
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] === undefined ||
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] !== identity ||
|
||||||
|
options.actor !== identity
|
||||||
|
) {
|
||||||
|
await journal.seal('refused', 'provider-identity-mismatch');
|
||||||
|
return localLifecycleResult('get', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'provider-identity-mismatch',
|
||||||
|
message: 'Runtime fleet identity, explicit actor, and requested identity must match.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
const fd = Number(options.outputFd);
|
const fd = Number(options.outputFd);
|
||||||
const stat = fstatSync(fd);
|
const stat = fstatSync(fd);
|
||||||
if (
|
if (
|
||||||
@@ -687,6 +701,7 @@ export async function executeCredentialGet(
|
|||||||
const prefix = new TextEncoder().encode(
|
const prefix = new TextEncoder().encode(
|
||||||
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
|
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
|
||||||
);
|
);
|
||||||
|
await journal.recordMutation('credential-issuance-authorized');
|
||||||
writeSync(fd, prefix);
|
writeSync(fd, prefix);
|
||||||
writeSync(fd, resolved.secret);
|
writeSync(fd, resolved.secret);
|
||||||
writeSync(fd, new TextEncoder().encode('\n\n'));
|
writeSync(fd, new TextEncoder().encode('\n\n'));
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ const SAFE_DECISIONS = new Set<string>([
|
|||||||
'token-mint-applied',
|
'token-mint-applied',
|
||||||
'token-binding-stored',
|
'token-binding-stored',
|
||||||
'tea-login-stored',
|
'tea-login-stored',
|
||||||
|
'tea-login-removed',
|
||||||
'provision-rollback-verified',
|
'provision-rollback-verified',
|
||||||
'rotate-rollback-verified',
|
'rotate-rollback-verified',
|
||||||
'token-revoke-applied',
|
'token-revoke-applied',
|
||||||
'wire-applied',
|
'wire-applied',
|
||||||
|
'credential-issuance-authorized',
|
||||||
'credential-issued',
|
'credential-issued',
|
||||||
'classification-correction',
|
'classification-correction',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const bindingSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
const credentialEnvelopeSchema = bindingSchema.extend({
|
||||||
|
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
|
||||||
|
});
|
||||||
|
|
||||||
export class CredentialStoreError extends Error {
|
export class CredentialStoreError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -102,6 +105,47 @@ export class FileCredentialResolver implements CredentialResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const envelopePath = join(
|
||||||
|
this.tokenDirectory,
|
||||||
|
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
|
||||||
|
root: this.tokenDirectory,
|
||||||
|
maxBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
const envelope = credentialEnvelopeSchema.safeParse(
|
||||||
|
JSON.parse(envelopeSnapshot.content.toString('utf8')),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!envelope.success ||
|
||||||
|
envelopeSnapshot.uid !== process.getuid?.() ||
|
||||||
|
(envelopeSnapshot.mode & 0o077) !== 0
|
||||||
|
) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'invalid-binding',
|
||||||
|
'credential envelope failed schema, owner, or mode validation',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
||||||
|
const digest = createHash('sha256').update(secret).digest('hex');
|
||||||
|
if (envelope.data.tokenDigest !== digest) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'credential-generation-mismatch',
|
||||||
|
'credential envelope digest does not match its token',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
identity,
|
||||||
|
estate,
|
||||||
|
host,
|
||||||
|
resolutionId: randomUUID(),
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isMissingFile(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
|
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
|
||||||
let snapshot: SecureFileSnapshot;
|
let snapshot: SecureFileSnapshot;
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +226,7 @@ export class FileCredentialStore {
|
|||||||
): {
|
): {
|
||||||
readonly token: string;
|
readonly token: string;
|
||||||
readonly binding: string;
|
readonly binding: string;
|
||||||
|
readonly envelope: string;
|
||||||
readonly prefix: string;
|
readonly prefix: string;
|
||||||
} {
|
} {
|
||||||
if (!IDENTITY.test(identity)) {
|
if (!IDENTITY.test(identity)) {
|
||||||
@@ -198,6 +243,7 @@ export class FileCredentialStore {
|
|||||||
return {
|
return {
|
||||||
token: join(this.tokenDirectory, `${prefix}.token`),
|
token: join(this.tokenDirectory, `${prefix}.token`),
|
||||||
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
|
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
|
||||||
|
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
|
||||||
prefix,
|
prefix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -206,14 +252,14 @@ export class FileCredentialStore {
|
|||||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||||
const token = validateSecret(Buffer.from(secret));
|
const token = validateSecret(Buffer.from(secret));
|
||||||
const binding = bindingSchema.parse({
|
const envelope = credentialEnvelopeSchema.parse({
|
||||||
...metadata,
|
...metadata,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
tokenDigest: createHash('sha256').update(token).digest('hex'),
|
tokenDigest: createHash('sha256').update(token).digest('hex'),
|
||||||
|
token: Buffer.from(token).toString('utf8'),
|
||||||
});
|
});
|
||||||
const suffix = randomUUID();
|
const suffix = randomUUID();
|
||||||
const tokenTemp = `${paths.token}.${suffix}.tmp`;
|
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
|
||||||
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
|
|
||||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||||
let lock;
|
let lock;
|
||||||
try {
|
try {
|
||||||
@@ -224,23 +270,19 @@ export class FileCredentialStore {
|
|||||||
'another mutation owns the identity lock',
|
'another mutation owns the identity lock',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
|
|
||||||
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
|
|
||||||
try {
|
try {
|
||||||
await tokenHandle.writeFile(token);
|
const handle = await open(envelopeTemp, 'wx', 0o600);
|
||||||
await tokenHandle.sync();
|
try {
|
||||||
await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8');
|
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
|
||||||
await bindingHandle.sync();
|
await handle.sync();
|
||||||
} finally {
|
} finally {
|
||||||
await tokenHandle.close();
|
await handle.close();
|
||||||
await bindingHandle.close();
|
}
|
||||||
}
|
await rename(envelopeTemp, paths.envelope);
|
||||||
try {
|
|
||||||
await rename(bindingTemp, paths.binding);
|
|
||||||
await rename(tokenTemp, paths.token);
|
|
||||||
await syncDirectory(this.tokenDirectory);
|
await syncDirectory(this.tokenDirectory);
|
||||||
} finally {
|
} finally {
|
||||||
await lock.close();
|
await lock.close();
|
||||||
|
await unlink(envelopeTemp).catch((): void => undefined);
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
await syncDirectory(this.tokenDirectory);
|
await syncDirectory(this.tokenDirectory);
|
||||||
}
|
}
|
||||||
@@ -279,6 +321,31 @@ export class FileCredentialStore {
|
|||||||
): Promise<CredentialBindingMetadataDto | undefined> {
|
): Promise<CredentialBindingMetadataDto | undefined> {
|
||||||
const paths = this.paths(identity, estate, host);
|
const paths = this.paths(identity, estate, host);
|
||||||
let snapshot: SecureFileSnapshot;
|
let snapshot: SecureFileSnapshot;
|
||||||
|
try {
|
||||||
|
const envelope = readRegularFileSecure(paths.envelope, {
|
||||||
|
root: this.tokenDirectory,
|
||||||
|
maxBytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
const parsed = credentialEnvelopeSchema.safeParse(
|
||||||
|
JSON.parse(envelope.content.toString('utf8')),
|
||||||
|
);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
identity: parsed.data.identity,
|
||||||
|
estate: parsed.data.estate,
|
||||||
|
host: parsed.data.host,
|
||||||
|
providerLogin: parsed.data.providerLogin,
|
||||||
|
tokenName: parsed.data.tokenName,
|
||||||
|
scopes: parsed.data.scopes,
|
||||||
|
createdAt: parsed.data.createdAt,
|
||||||
|
tokenDigest: parsed.data.tokenDigest,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isMissingFile(error)) throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
snapshot = readRegularFileSecure(paths.binding, {
|
snapshot = readRegularFileSecure(paths.binding, {
|
||||||
root: this.tokenDirectory,
|
root: this.tokenDirectory,
|
||||||
@@ -306,9 +373,20 @@ export class FileCredentialStore {
|
|||||||
if (config === undefined) return [];
|
if (config === undefined) return [];
|
||||||
const names = await readdir(this.tokenDirectory);
|
const names = await readdir(this.tokenDirectory);
|
||||||
const prefix = `${config.tokenPrefix}-`;
|
const prefix = `${config.tokenPrefix}-`;
|
||||||
return names
|
return [
|
||||||
.filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token'))
|
...new Set(
|
||||||
.map((name): string => name.slice(prefix.length, -'.token'.length))
|
names.flatMap((name): string[] => {
|
||||||
|
if (!name.startsWith(prefix)) return [];
|
||||||
|
if (name.endsWith('.token')) {
|
||||||
|
return [name.slice(prefix.length, -'.token'.length)];
|
||||||
|
}
|
||||||
|
if (name.endsWith('.credential.json')) {
|
||||||
|
return [name.slice(prefix.length, -'.credential.json'.length)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
.filter((identity): boolean => IDENTITY.test(identity))
|
.filter((identity): boolean => IDENTITY.test(identity))
|
||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
@@ -321,6 +399,9 @@ export class FileCredentialStore {
|
|||||||
await unlink(paths.binding).catch((error: unknown): void => {
|
await unlink(paths.binding).catch((error: unknown): void => {
|
||||||
if (!isMissingFile(error)) throw error;
|
if (!isMissingFile(error)) throw error;
|
||||||
});
|
});
|
||||||
|
await unlink(paths.envelope).catch((error: unknown): void => {
|
||||||
|
if (!isMissingFile(error)) throw error;
|
||||||
|
});
|
||||||
await syncDirectory(this.tokenDirectory);
|
await syncDirectory(this.tokenDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ describe('credential lifecycle', (): void => {
|
|||||||
authority,
|
authority,
|
||||||
lifecycleProvider,
|
lifecycleProvider,
|
||||||
store,
|
store,
|
||||||
|
teaStore,
|
||||||
{ stateRoot: root, actor: 'seat' },
|
{ stateRoot: root, actor: 'seat' },
|
||||||
);
|
);
|
||||||
expect(result.outcome).toBe('ok');
|
expect(result.outcome).toBe('ok');
|
||||||
@@ -203,6 +204,7 @@ describe('credential lifecycle', (): void => {
|
|||||||
authority,
|
authority,
|
||||||
lifecycleProvider,
|
lifecycleProvider,
|
||||||
store,
|
store,
|
||||||
|
teaStore,
|
||||||
{ stateRoot: root, actor: 'seat' },
|
{ stateRoot: root, actor: 'seat' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export async function revokeCredential(
|
|||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
provider: GiteaLifecycleProvider,
|
provider: GiteaLifecycleProvider,
|
||||||
store: FileCredentialStore,
|
store: FileCredentialStore,
|
||||||
|
teaStore: TeaLoginStore,
|
||||||
options: LifecycleOptions,
|
options: LifecycleOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
const journal = await openLifecycleJournal('revoke', request, options);
|
const journal = await openLifecycleJournal('revoke', request, options);
|
||||||
@@ -292,6 +293,11 @@ export async function revokeCredential(
|
|||||||
auditState: 'sealed',
|
auditState: 'sealed',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await teaStore.remove(request.identity, request.host);
|
||||||
|
if (teaStore.readBack(request.identity, request.host) !== undefined) {
|
||||||
|
throw new Error('Tea login still exists after revocation');
|
||||||
|
}
|
||||||
|
await journal.recordMutation('tea-login-removed');
|
||||||
await store.remove(request.identity, request.estate, request.host);
|
await store.remove(request.identity, request.estate, request.host);
|
||||||
await journal.seal('ok', 'revoke-verified');
|
await journal.seal('ok', 'revoke-verified');
|
||||||
return lifecycleResult('revoke', request, {
|
return lifecycleResult('revoke', request, {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { TeaLoginStore } from './tea-login-store.js';
|
||||||
|
|
||||||
|
let root: string | undefined;
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
||||||
|
root = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('host-bound Tea login store', (): void => {
|
||||||
|
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
|
||||||
|
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
||||||
|
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
|
||||||
|
await Promise.all([
|
||||||
|
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||||
|
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
||||||
|
).toBe(true);
|
||||||
|
await store.remove('seat', 'git.one.invalid');
|
||||||
|
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
||||||
|
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
||||||
|
name: 'seat',
|
||||||
|
host: 'git.two.invalid',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { open, rename } from 'node:fs/promises';
|
import { open, rename, unlink } from 'node:fs/promises';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { parse, stringify } from 'yaml';
|
import { parse, stringify } from 'yaml';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -41,6 +41,23 @@ const loginSchema = z
|
|||||||
.passthrough();
|
.passthrough();
|
||||||
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
|
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
|
||||||
|
|
||||||
|
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
||||||
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await open(path, 'wx', 0o600);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
|
||||||
|
await new Promise<void>((resolve): void => {
|
||||||
|
setTimeout(resolve, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new TeaLoginStoreError(
|
||||||
|
'conflicting-credential-mutation',
|
||||||
|
'Tea configuration lock did not become available',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function missing(error: unknown): boolean {
|
function missing(error: unknown): boolean {
|
||||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||||
}
|
}
|
||||||
@@ -54,42 +71,51 @@ export class TeaLoginStore {
|
|||||||
}
|
}
|
||||||
const directory = dirname(this.configPath);
|
const directory = dirname(this.configPath);
|
||||||
ensureManagedDirectory(directory, directory);
|
ensureManagedDirectory(directory, directory);
|
||||||
let current: TeaConfig = { logins: [] };
|
const lockPath = `${this.configPath}.lock`;
|
||||||
|
const lock = await acquireLock(lockPath);
|
||||||
try {
|
try {
|
||||||
const snapshot = readRegularFileSecure(this.configPath, {
|
let current: TeaConfig = { logins: [] };
|
||||||
root: directory,
|
try {
|
||||||
maxBytes: 1024 * 1024,
|
const snapshot = readRegularFileSecure(this.configPath, {
|
||||||
});
|
root: directory,
|
||||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
maxBytes: 1024 * 1024,
|
||||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
});
|
||||||
|
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||||
|
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||||
|
}
|
||||||
|
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||||
|
if (!decoded.success) {
|
||||||
|
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||||
|
}
|
||||||
|
current = { logins: decoded.data.logins };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!missing(error)) throw error;
|
||||||
}
|
}
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
const token = Buffer.from(secret).toString('utf8');
|
||||||
if (!decoded.success) {
|
const record: TeaLoginRecord = {
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
name: identity,
|
||||||
|
url: `https://${host}`,
|
||||||
|
token,
|
||||||
|
user: identity,
|
||||||
|
default: false,
|
||||||
|
};
|
||||||
|
const logins = current.logins.filter(
|
||||||
|
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
|
||||||
|
);
|
||||||
|
logins.push(record);
|
||||||
|
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||||
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
|
try {
|
||||||
|
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
||||||
|
await handle.sync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
}
|
}
|
||||||
current = { logins: decoded.data.logins };
|
await rename(temp, this.configPath);
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!missing(error)) throw error;
|
|
||||||
}
|
|
||||||
const token = Buffer.from(secret).toString('utf8');
|
|
||||||
const record: TeaLoginRecord = {
|
|
||||||
name: identity,
|
|
||||||
url: `https://${host}`,
|
|
||||||
token,
|
|
||||||
user: identity,
|
|
||||||
default: false,
|
|
||||||
};
|
|
||||||
const logins = current.logins.filter((login): boolean => login.name !== identity);
|
|
||||||
logins.push(record);
|
|
||||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
|
||||||
try {
|
|
||||||
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await lock.close();
|
||||||
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
}
|
}
|
||||||
await rename(temp, this.configPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
|
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
|
||||||
@@ -128,26 +154,33 @@ export class TeaLoginStore {
|
|||||||
|
|
||||||
async remove(identity: string, host: string): Promise<void> {
|
async remove(identity: string, host: string): Promise<void> {
|
||||||
const directory = dirname(this.configPath);
|
const directory = dirname(this.configPath);
|
||||||
const snapshot = readRegularFileSecure(this.configPath, {
|
const lockPath = `${this.configPath}.lock`;
|
||||||
root: directory,
|
const lock = await acquireLock(lockPath);
|
||||||
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 {
|
try {
|
||||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
const snapshot = readRegularFileSecure(this.configPath, {
|
||||||
await handle.sync();
|
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);
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await lock.close();
|
||||||
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
}
|
}
|
||||||
await rename(temp, this.configPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readBack(
|
readBack(
|
||||||
|
|||||||
Reference in New Issue
Block a user